包和模块

rust有4层:

  • 工作空间(workspace): Set<packages>
  • 项目(packages): 由cargo管理,这是项目层级,可以看作Set<crate>,特征是有Cargo.toml。一个package只能有一个lib crate,但是可以有多个binary crate
  • 包(crate): 这是最小编译单元。层级比go的package大,类似java的Sub-module,可以看作独立的项目,但是crate和文件树没有强对应关系,本质是一个入口(libbinary)+依赖链的模式。同样类似的Set<mod>
  • 模块(module): 代码构成单元,对象命名空间

Tip

理解语义即可,rust的命名简直瞎搞。

package

二进制package

表示默认的crate是二进制的,也就是src/main.rs是入口文件,此时crate的名称同package名。

cargo new my-project

库package

crate是lib模式,入口文件为src/lib.rs,此时crate的名称依然是package名。

cargo new my-lib --lib

Tip

lib package可以和binary package同名,没有影响

典型package

├── Cargo.toml
├── Cargo.lock
├── src
│   ├── main.rs  # binary crate,和pacakge同名
│   ├── lib.rs   # lib crate, 和pacage同名
│   └── bin
│       └── main1.rs # binary crate,名称就是文件名
│       └── main2.rs # 同上
├── tests
│   └── some_integration_tests.rs
├── benches
│   └── simple_bench.rs
└── examples
    └── simple_example.rs

模块

mod类型

  • 文件mod: 只有在其他文件中通过显示mod声明时,这个被声明的文件才变成文件mod,否则它就是一个普通的文件,rust甚至不会看见它。这种mod声明本质是建立链接。
  • mod block: 直接声明的mod

这2个mod在使用上没有任何区别,只是为了方便理解语义。

要点:

  1. 模块必须在crate的语境下
  2. rust的模块本质是编译链,这是和其他语言最大的区别,比如go,ts,python,lua等的”模块”系统,和文件系统深度绑定。rust的模块跟随编译链走,从编译链的入口模块开始,最后形成一棵以入口模块为根的模块树(不是标准多叉树),所以广义的crate指的实际是这棵树。
  3. 目录必须转换为模块才能被找到,有2种方式:
    1. <dir>/mod.rs。对,就是python/lua的模式
    2. <div>.rs,创建一个同级同名的rs文件
  4. 文件模块使用前,必须显示使用mod来声明(链接到树上),rust完全不会主动去文件系统中找
  5. 模块和文件不是一对一,一个文件可以定义多个mod,这个mod可以是文件mod或者mod block
  6. mod可以嵌套,当然,不然目录也没必要转换为mod了。mod block同样支持嵌套。
  7. 只有同一个作用域的对象(mod也是对象)才是相互可见的,否则必须显示pub修饰。

Tip

这里也能看出来rust不太喜欢主动替用户做决定。

use

"use" identifier{"::"identifier}[ "as" identifier]";";

很多语言都是直接从文件树上找,但是rust必须先用mod形成树,然后use才能从这棵树上找对象。

use第三方crate

当第三方依赖添加进来后,本质是它的crate暴露在了项目crate的runtime,默认暴露的还有内置crate,比如std

所以可以总结use的含义: 从runtime中的crate和当前模块树搜索对象。

方式

  1. use的对象在当前模块的作用域中,属于当前模块了,当然也可以暴露出去,使用pub修饰即可
  2. use多个,可以使用{obj,...}表示
  3. self不在use的最前面时,表示导入的这个模块本身,use std::io::{self, Write};
  4. *导入所有,use std::collections::*;

绝对路径和相对路径

use中,关键字crate特指当前crate的根mod,类似/的意义。

mod front_of_house {
    mod hosting {
        fn add_to_waitlist() {}
    }
}
 
pub fn eat_at_restaurant() {
    // 绝对路径
    crate::front_of_house::hosting::add_to_waitlist();
 
    // 相对路径
    front_of_house::hosting::add_to_waitlist();
}

最佳实践

并没有规定和建议强制使用哪种形式,但是有个原则,局部的用相对,全局的用绝对

特殊标识

  • super: 类似../的概念,用于嵌套mod
  • self: 表示当前mod

可见性

pub定义可见,注意每一层都要显示暴露。

还可以更加细化:

  • pub 意味着可见性无任何限制
  • pub(crate) 表示在当前包可见
  • pub(self) 在当前模块可见,和什么都不修饰的private行为一致,当前和子模块中可以访问。这个主要用于宏编程的显示可见性设置。
  • pub(super) 在父模块可见
  • pub(in ) 表示在某个路径代表的模块中可见,其中 path 必须是父模块或者祖先模块

基础

变量

  1. rust的变量,默认是immutable的,必须显示的使用mut,比如let mut a : i32 = 1来声明。

  2. rust也可以多次let同一个变量名,来赋值对应作用域之前的。这和重新赋值不同,这是重新分配了一次内存(正常情况下),后面对这个变量的使用,都使用的是最新地址,而且原来的那个内存不会因为被遮蔽而释放,它依然遵循常规的释放逻辑。

  3. rust支持非常方便的解构赋值

    let (a, b, c):(i32, i32, i32) = (1, 2, 3);

基本类型

类型的标识有多种形式, 以下表示的含义完全相同:

let a: i32 = 1; // 1此时就是i32,如果a: i64 = 1,那1就是i64
let a: i32 = 1i32; // 显示指定
let a: i32 = 1_i32; // 显示指定
let a = 1i32; // a被推断为i32
let a = 1_i32; // a被推断为i32

数值类型

整数

可以参考chapter4的整数类型.

同样iszieusize大小,取决于CPU的位数。

Note

rust的整数表示,比如i8, i16, i32, u8, u16, u32, f32, f64等非常直观且容易记忆。

默认使用i32

溢出

这是所有静态类型语言都会遇到的问题。 有符号整数的大小: -2^n-1 ~ (2^n-1)-1 无符号整数的大小: 0 ~ (2^n-1)-1 rust的溢出处理默认使用”补码循环溢出”逻辑,表现上就是高位丢弃,比如255u8 + 1u8 就会变成0u8

也可以显示处理,给方法加上对应的前缀:

  • 使用 wrapping_* 方法在所有模式下都按照补码循环溢出规则处理,例如 wrapping_add
  • 如果使用 checked_* 方法时发生溢出,则返回 None 值
  • 使用 overflowing_* 方法返回该值和一个指示是否存在溢出的布尔值
  • 使用 saturating_* 方法,可以限定计算后的结果不超过目标类型的最大值或低于最小值
// all is true
assert_eq!(255u8.wrapping_add(1), 0);
assert_eq!(255u8.checked_add(1), None);
assert_eq!(255u8.overflowing_add(1), (0, true));
assert_eq!(u8::MAX.saturating_add(1), u8::MAX);

浮点数

默认为f64 基于IEEE754实现,可以参考IEEE754浮点数标准浮点数存储标准IEEE-754也进行了简单说明。 根据标准也可以知道,二进制是无法精确的表示十进制的浮点数的,所以计算机的浮点数都是近似的表达,书中也明确提到:

  • 避免在浮点数上测试相等性。因为2个不同的小数的浮点数位可能相同,毕竟精度会丢失。

    assert!(0.1 + 0.2 == 0.3); // assertion failed
    assert!(0.1f32 + 0.2f32 == 0.3f32); // assertion successed,32位的近似表达又是一致的
    assert!(0.1 + 0.1 == 0.2); // assertion true,0.1 + 0.1的近似表达 == 0.2的近似表达
  • 当结果在数学上可能存在未定义时,需要格外的小心

Note

基本使用IEEE754实现的,都有这个问题,包括python, java等等

更直观的例子: 在浮点表达上,32位由于精度较差,刚好0.1 + 0.20.3的近似表达就是一致的,但是64位则不一样,因为它们的精度更高。

let abc: (f32, f32, f32) = (0.1, 0.2, 0.3);
let xyz: (f64, f64, f64) = (0.1, 0.2, 0.3);
println!("abc (f32)");
println!(" 0.1 + 0.2: {:x}", (abc.0 + abc.1).to_bits()); // 3e99999a
println!("       0.3: {:x}", (abc.2).to_bits()); // 3e99999a
println!("xyz (f64)");
println!(" 0.1 + 0.2: {:x}", (xyz.0 + xyz.1).to_bits()); // 3fd3333333333334
println!("       0.3: {:x}", (xyz.2).to_bits()); // 3fd3333333333333
assert!(abc.0 + abc.1 == abc.2); // successed
assert!(xyz.0 + xyz.1 == xyz.2); // failed
NaN

数学上未定义的结果,被处理为NaN(not a number),NaN不能被比较。

基本运算

  1. 只有同类型的才能进行运算,没有所谓的隐式转换。
  2. 长数字,比如100,000,可以表示为100_000

位运算

chapter15中第4点。

Caution

移位不能超过类型的位数,比如u8, <<, >>最多只能移7位

Range

模型基本类似python的range

  • ..: 不包含右边界
  • ..=: 包含右边界

字符本质是u32(c是u8),所以也可以用于range。

for i in 1..=5 {
    println!("{}", i);
}
for c in 'a'..'e' {
    println!("{}", c);
}

As 类型转换

let a: i32 = 10; 
let b: u16 = 100; // 将 i32 转换为 f64 
let c = a as f64; // 将 u16 转换为 i32 
let d = b as i32; // 甚至可以将浮点数直接截断为整数 
let e = 3.14_f32 as i32; // 结果为 3

这个是比较粗暴的转换,如果需要更安全的可以使用try_into()

有理数和复数

本质就是非具体的数值表达,而是”结构”表达, 毕竟计算机无法表示所有数(连浮点数都无法准确表示)。结构表达可以规避一些问题比如精度丢失,长度太长等,比如1/3,根本无法准确表示,但是却可以直接用1/3表示。

Note

对于很多精度要求高的场景,比如财务,用i64,f64有时候是无法表达的,这些类型都有上限。

甚至有些数自然界都没有,比如复数,只能结构表达。

Tip

num库提供了复数类型,可以用num::complex::Complex表示。

字符、布尔、单元类型

  1. char. 不同于c的一个字节,rust中的char是一个Unicode码点(严格来说是标量值,筛选过的码点),用4个字节表示,所以可以表示任意Unicode字符,包括汉字。和字符非常相似。

  2. bool. true or false, 一个字节表示

  3. 单元类型. 用()表示,且拥有唯一的值(),可以用于函数返回值,比如fn main()就是返回()()内存占用为0,但是依然分配地址,但是这个地址可以看作是”虚拟的”,实际不存在。

    None,void, 但是为什么用empty tuple?

    作用像

语句和表达式

  • 语句:let a = 1;
  • 表达式:1 + 2, 如果加上;就会变成语句

表达式总是会返回值,语句则没有。

下面的都是表达式:

  • 1, 就返回1
  • { let x = 1; x + 1 }, 没错,语句块也是,所以可以把函数体也当作一个表达式。最后一个表达式的返回值就是语句块的返回值,如果块的最后不是表达式,则使用隐含的()作为表达式,也就是返回()
  • if xxx { 1 } else { 2 }, 也是表达式
  • some_fn()

Tip

所以函数可以省略return,因为默认会返回最后一个表达式

函数

没有什么特殊的,关键字,函数名,形参列表,返回类型,函数体。

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

没有或显示使用()作为返回类型,表示函数返回(),该类型的唯一值。

TypescriptPython都有Never的概念,作为返回类型时,表示永不返回,也就是不会执行到return(比如异常,死循环等等),rust用!表示同样的概念(这是什么奇怪的关键字想法)。

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

所有权和借用

内存管理方式

  • 手动GC: c, c++
  • 自动GC: go, java, python等等
  • 通过所有权管理: rust

所有权管理的本质,依然是用户主导,只是释放内存的逻辑被融合进了语义中,不再是显示的释放,完全依靠编译器去实现,简单的说c是纯手动,rust则提供了开关。

内存分区

一般的程序运行时大致有3个内存区域:

  • : 函数的调用都会进行入栈和出栈,栈用于管理地址跳转逻辑,变量的内存分配,返回值等等。具体查看stack;
  • : 操作系统视角,就是一块内存区域,用于存储复杂数据。栈一般是比较小的。
  • 数据段: 静态数据,全局数据,一般只读,随着程序加载写入.

类型与内存分配

在不同的语言中,都有这种逻辑,根据类型,构造的对象,一般会直接分配1个或2个内存,即所谓的引用类型和值类型。 rust的所有权、借用、共享等对象之间的关系,都和类型强关联,通过类型来实现。

内存对象之间的关系

这里的内存对象指运行时分配的内存地址内容,且和内存位置无关,不管栈、堆还是数据段。

内存对象之间的关系,本质是资源管理的方式具现,比如:

  • 所有权: 我拥有你,我管理你,我的周期结束,你的周期也结束
  • 借用:所有权没有转移,我们建立临时的关系,但是我的生命周期不允许超过你
  • 共享:我们都有你的所有权,通过引用计数的方式管理资源

说明

本文把分配的内存对象,其中的入口对象,称为访问入口对象

所有权

在处理2个内存对象类型的资源释放的问题时,传统的多个引用(多个所有者),释放时必须检查所有引用者的生命周期是否都结束了,否则会造成空指针,而如果有引用没有正确的释放,又会造成内存泄露。

rust引入所有权,明确管理者,且通常只有一个,这样管理就变的非常简单,我结束,你释放。

那内存对象之间怎么建立什么样的关系,谁来决定?

类型,类型构造对象(逻辑上的对象),类型决定了内存分配方式,类型决定了内存对象之间的关系。 总之类型决定语义,类型决定关系。

Tip

逻辑对象,类型构建的内存对象的统一,狭义上指它的访问入口对象。

类型构造形式对象关系代码表现
i32标量各自拥有(Copy)let b = a; // a,b 都有效
Box<T>拥有指针唯一拥有(Move)let b = a; // a 失效
&T不可变引用借用(只读)let r = &a; // 不拥有
&mut T可变引用独占借用(可修改)let r = &mut a; // 独占
Rc<T>共享指针共享拥有Rc::clone(&a) // 计数+1
Option<T>可选可能有/无Some(v) / None
Vec<T>集合拥有多个vec![a,b,c] // 拥有所有
*mut T裸指针无关系(unsafe)自己管理

根据上表的内容,不同的类型意味着不同的关系和管理模式。

  • 通常分配一个内存的,它的所有者就是自己,它的赋值总是COPY,它不管理其他资源,它的释放就是简单的释放自己
  • 通常分配两个内存的,它就有所有者了,它的赋值总是MOVE,转移了所有者,释放它,会同时释放它拥有的。

而借用和共享等的形式,也有对应的类型,他们也涉及多个内存对象:

  • 借用,给予访问权限,但是编译器不允许借用者的生命周期超过被借用者,所以这是一个局部优化措施
  • 共享,引用计数的形式,计数归0,就释放。这是妥协?

关于只分配一个内存的,比如i32,它的所有者就是自己,它出栈,内存释放,这是它在栈中的情况,如果这种类型在堆中呢,怎么释放? rust不允许这种以裸值的形式出现在堆中,因为这无法安全的暴露它的访问方式,除非使用裸指针,但是这是unsafe的,需要手动释放。实际上所有的访问入口对象都不能以裸值的形式出现在堆中,除非unsafe

Tip

同一个值,不同的类型,有不同的逻辑表达,裸指针也是指针,但是rust并不做额外的控制,它此时就像c的指针一样,完全由用户管理。

引用和借用

如果只有所有权转移,那程序的表达能力将非常弱,需要大量的控制代码,心智负端极大。有些轻量使用场景,我只是想临时获取引用,这时候使用简单的“借用”就可以了。

Tip

这里也可以看出来,一个普通的逻辑行为,都做了不同的表达,实际上整个rust中有大量类似的行为,在其他语言中普通的操作,到这里都进行了细化,以便更精确的控制。理解上要困难得多,对比c,多了大量的抽象控制表达,对比go,把控制权交给了用户。

取址
fn main() {
    let x = 5;
    let y = &x;
 
    assert_eq!(5, x);
    assert_eq!(5, *y);
}

&取址操作,注意这和c,go的不太一样的是,会根据类型获取到瘦指针或胖指针。

Tip

瘦指针: 纯地址 胖指针: 地址 + 其他元信息

比如go的slice,map, string,在栈中实际都是所谓胖指针,但是go并不把他们当作指针,而在rust中他们都是指针。

&取址后的类型是对应的&<type>,这个和*<type>裸指针的区别是,它是安全的,而*<type>是不安全的,也就是不保证对应的地址有内容。但是他们2者表达的是一个内容,即指针类型(包括胖指针)。

借用

通过取址,获得访问权限,就是“借用”。

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() // s -> s1,操作时自动解引用
}

这看起来像间接指针,实际也是,但是表达的逻辑是借用。

可变引用(借用)
fn main() {
    let mut s = String::from("hello"); // 这里也必须是可修改的
 
    change(&mut s); // &mut 可变借用
}
 
fn change(some_string: &mut String) {
    some_string.push_str(", world");
}

注意: 同一作用域,同一数据只能有一个可变,且可变和不可变不能同时存在。

悬垂引用

不管用什么概念来包装,此时既然是取址,那就可能存在被取址的释放了,但是它的地址还存在某处,形成空指针。 不过rust在编译阶段就阻止了这种行为,帮你避免了panic。

// 编译错误
fn main() {
	// dangle执行完,s就释放了,s的地址返回给了reference_to_nothing,它指向不存在的内存
    let reference_to_nothing = dangle();
}
 
fn dangle() -> &String {
    let s = String::from("hello");
 
    &s
}