跳转至

Rust

命名规范

变量绑定/解绑

变量遮蔽(shadowing)

Rust 允许声明相同的变量名,之后的声明遮蔽掉之前的声明,涉及一次内存对象再分配,而非修改同一内存的值

fn main() {
    let x = 5;
    // 在main函数的作用域内对之前的x进行遮蔽
    let x = x + 1;
    {
        // 在当前的花括号作用域内,对之前的x进行遮蔽
        let x = x * 2;
        println!("The value of x in the inner scope is: {}", x);
    }
    println!("The value of x is: {}", x);
}

基本类型

可以在声明变量时指明类型,比如 let a: i32 = 1let a: = 1_i32 由以下组成:

  • 数值类型:
    • 有符号整数:i8, i16, i32, i64, i128, isize
    • 无符号整数:u8u16, u32, u64, u128, usize
    • 浮点数:f32(单精度), f64(双精度,默认)

注意: isize 和 usize 的大小取决于目标平台的指针大小(32 位平台为 4 字节,64 位平台为 8 字节)

  • 字符串切片:&str
  • 布尔类型:truefalse
  • 字符类型:char 表示单个 Unicode 字符(存储为 4 字节),如 A, , 😻
  • 单元类型:(),其唯一可能的值也是 ()

浮点数陷阱

因为 f32 , f64 上的比较运算实现的是 std::cmp::PartialEq 特征(类似其他语言的接口),但是并没有实现 std::cmp::Eq 特征,但是后者在其它数值类型上都有定义,说了这么多,可能大家还是云里雾里,用一个例子来举例:

Rust 的 HashMap 数据结构,是一个 KV 类型的 Hash Map 实现,它对于 K 没有特定类型的限制,但是要求能用作 K 的类型必须实现了 std::cmp::Eq 特征,因此这意味着你无法使用浮点数作为 HashMap 的 Key,来存储键值对,但是作为对比,Rust 的整数类型、字符串类型、布尔类型都实现了该特征,因此可以作为 HashMap 的 Key。

为了避免上面说的两个陷阱,你需要遵守以下准则: + 避免在浮点数上测试相等性 + 当结果在数学上可能存在未定义时,需要格外的小心

字符类型 char

Rust 的字符不仅仅是 ASCII,所有的 Unicode 值都可以作为 Rust 字符,包括单个的中文、日文、韩文、emoji 表情符号等等,都是合法的字符类型。Unicode 值的范围从 U+0000 ~ U+D7FFU+E000 ~ U+10FFFF。不过“字符”并不是 Unicode 中的一个概念,所以人在直觉上对“字符”的理解和 Rust 的字符概念并不一致。

char 类型表示一个 Unicode 标量值(Unicode Scalar Value),它占用固定的 4 字节(32 位) 内存(与 UTF-32 编码单元一致)

Rust 的字符只能用 '' 来表示, "" 是留给字符串的。

布尔类型 bool

Rust 中的布尔类型有两个可能的值:true 和 false,布尔值占用内存的大小为 1 个字节:

单元类型 unit

就是 (),当函数没有返回值时就返回 unit

语句和表达式

fn add_with_extra(x: i32, y: i32) -> i32 {
    let x = x + 1; // 语句
    let y = y + 5; // 语句
    x + y // 表达式
}

语句会执行一些操作但是不会返回一个值,而表达式会在求值后返回一个值,因此在上述函数体的三行代码中,前两行是语句,最后一行是表达式。

表达式

调用一个函数是表达式,因为会返回一个值,调用宏也是表达式,用花括号包裹最终返回一个值的语句块也是表达式,总之,能返回值,它就是表达式:

fn main() {
    let y = {
        let x = 3;
        x + 1
    };

    println!("The value of y is: {}", y);
}

最后,表达式如果不返回任何值,会隐式地返回一个 ()

函数

  • 函数名使用蛇形命名法(snake case),例如 fn add_two() {}

  • 函数的位置可以随便放,Rust 不关心我们在哪里定义了函数,只要有定义即可

  • 每个函数参数都需要标注类型

函数的返回值就是函数体最后一条表达式的返回值,当然我们也可以使用 return 提前返回

返回值为 !

表示永不返回,一般用于程序崩溃时调用

fn dead_end() -> ! {
  panic!("你已经到了穷途末路,崩溃吧!");
}

所有权与借用

在以往,内存安全几乎都是通过 GC 的方式实现,但是 GC 会引来性能、内存占用以及 Stop the world 等问题,在高性能场景和系统编程上是不可接受的,因此 Rust 采用了与 ( 不 ) 众 ( 咋 ) 不 ( 好 ) 同 ( 学 )的方式:所有权系统。

预热:栈与堆

栈与堆都是在程序运行时提供内存的数据结构。

栈的分配方式是最近分配的内存最先释放,在内存中的分配是从上(栈底)向下(栈顶)的,用于分配已知固定大小的内存块;

堆的分配方式是从堆中找到一块足够的内存进行分配,通常是用于分配编译时未知、运行时才能知道的内存大小;

性能比较

数据在栈上分配性能更好,因为不需要函数调用,直接在栈顶下方存入数据即可;而在堆上分配,操作系统必须首先找到一块足够存放数据的内存空间,接着做一些记录为下一次分配做准备,如果当前进程分配的内存页不足时,还需要进行系统调用来申请更多内存。

因为堆上的数据缺乏组织,比如这个对象对应的内存块大小未知,比如堆并不是按照固定顺序来存放变量的。因此跟踪这些数据何时分配和释放是非常重要的,否则堆上的数据将产生内存泄漏 —— 这些数据将永远无法被回收。这就是 Rust 所有权系统为我们提供的强大保障。

所有权

所有的程序都必须和计算机内存打交道,如何从内存中申请空间来存放程序的运行内容,如何在不需要的时候释放这些空间,成了重中之重,也是所有编程语言设计的难点之一。在计算机语言不断演变过程中,出现了三种流派:

  • 垃圾回收机制(GC),在程序运行时不断寻找不再使用的内存,典型代表:Java、Go
  • 手动管理内存的分配和释放, 在程序中,通过函数调用的方式来申请和释放内存,典型代表:C++
  • 通过所有权来管理内存,编译器在编译时会根据一系列规则进行检查

其中 Rust 选择了第三种,最妙的是,这种检查只发生在编译期,因此对于程序运行期,不会有任何性能上的损失。

所有权原则

  1. Rust 中每一个值都被一个变量所拥有,该变量被称为值的所有者
  2. 一个值同时只能被一个变量所拥有,或者说一个值只能拥有一个所有者
  3. 当所有者(变量)离开作用域范围时,这个值将被丢弃(drop)

动态类型 String

let s: &str = "hello"; // string literal
虽然字符串字面量是固定的,但是很多时候无法在编译时确定字符串内容和长度(比如用户输入),这个时候就需要用到堆分配内存,rust 有专门的 String 动态类型来处理字符串。

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

let mut s = String:from("hello");
s.push_str(", world!"); // append operation

对于 String 这样的非基本类型,就必须遵循上述所有权原则。当出现 let s2 = s1; 语句时,实际上发生的是所有权转移,s1 不再有效,将对象转移到 s2;而当 s2 离开自己的作用域后,会自动调用 drop 函数并清理它的堆内存。

对于非基本类型的对象,形似 let s2 = s1; 的拷贝操作都会转移所有权,进行浅拷贝。

比如 String,包含

  • 指针指向实际存在堆上的字符串
  • 容量
  • 已有长度

则只拷贝这三者,而不会将实际存储的字符串也拷贝一遍;

如果需要深拷贝,则需要写成 let s2 = s1.clone();;但是,这样相比浅拷贝,性能差很多

简单类型的拷贝 (copy)

对于以下类型的变量,他们是存储在栈上的,赋值就是深拷贝,而且旧的变量在被赋值给其他变量后仍然可用

  • 所有整数类型,比如 u32
  • 布尔类型,bool,它的值是 true 和 false
  • 所有浮点数类型,比如 f64
  • 字符类型,char
  • 元组,当且仅当其包含的类型也都是 Copy 的时候。比如,(i32, i32) 是 Copy 的,但 (i32, String) 就不是
  • 不可变引用 &T ,例如 &str;

但是注意:可变引用 &mut T 是不可以 Copy 的,这在后面会说到,是因为对任何对象,同一时刻只能有一个可变引用,或者说只能有一个可写的进程/线程。

这是 rust 内存安全特性的生动体现。

函数调用的值传递

rust 中函数调用时,会将对象传入函数的形参中,这时 caller 中的变量失效;callee 返回时,如果不返回对象,那么这块内存就直接 drop

以下是一个生动的例子:

fn main() {
    let s = String::from("hello");  // s 进入作用域
    takes_ownership(s);             // s 的值移动到函数里 ...
                                    // ... 所以到这里不再有效
    let x = 5;                      // x 进入作用域
    makes_copy(x);                  // x 应该移动函数里,
                                    // 但 i32 是 Copy 的,所以在后面可继续使用 x

} // 这里, x 先移出了作用域,然后是 s。但因为 s 的值已被移走,
  // 所以不会有特殊操作

fn takes_ownership(some_string: String) { // some_string 进入作用域
    println!("{}", some_string);
} // 这里,some_string 移出作用域并调用 `drop` 方法。占用的内存被释放

fn makes_copy(some_integer: i32) { // some_integer 进入作用域
    println!("{}", some_integer);
} // 这里,some_integer 移出作用域。不会有特殊操作

上面的例子函数返回时自动丢弃了 String 对象,如果回到 main 时不想丢弃,需要有一个新的变量来获取它的所有权:

fn main() {
    let s1 = gives_ownership();         // gives_ownership 将返回值
                                        // 移给 s1
    let s2 = String::from("hello");     // s2 进入作用域
    let s3 = takes_and_gives_back(s2);  // s2 被移动到
                                        // takes_and_gives_back 中,
                                        // 它也将返回值移给 s3
} // 这里, s3 移出作用域并被丢弃。s2 也移出作用域,但已被移走,
  // 所以什么也不会发生。s1 移出作用域并被丢弃
fn gives_ownership() -> String {             // gives_ownership 将返回值移动给
                                             // 调用它的函数
    let some_string = String::from("hello"); // some_string 进入作用域.
    some_string                              // 返回 some_string 并移出给调用的函数
}
// takes_and_gives_back 将传入字符串并返回该值
fn takes_and_gives_back(a_string: String) -> String { // a_string 进入作用域
    a_string  // 返回 a_string 并移出给调用的函数
}

引用与借用

如果像上面这样传对象,那么必须在返回值中带上对象,很麻烦。于是需要引用和借用。

借用就是通过引用的方式,获取对象的操作权,但不持有所有权。这样,就能有很多不同变量同时指向同一对象。

但是为了保证内存安全,不存在竞争,可变引用至多能有一个:

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

let r1 = &s; // 没问题
let r2 = &s; // 没问题
let r3 = &mut s; // 大问题

此外,rust 会在编译期就保证不会出现无效引用,也就是说,如果一个引用在所指对象生命周期结束后依然存在,编译器就会报错。

类型转换

TBD