Rust教程-01-变量绑定与解构

Rust教程-01-变量绑定与解构

01-变量绑定与解构

变量命名

在命名方面,和其他语言没有区别,不过当给变量命名时,需遵循 Rust 命名规范1

Rust 语言有一些关键字keywords),和其他语言一样,这些关键字都是被保留给 Rust 语言使用的,因此,它们不能被用作变量或函数的名称。在 Rust 关键字2 中可找到关键字列表。

变量绑定

在其它语言中,我们用 var a = "hello world" 的方式给 a 赋值,也就是把等式右边的 "hello world" 字符串赋值给变量 a,而在 rust 中,我们这样写:let a = "hello world",同时给这个过程起了另一个名字:变量绑定

为何不用赋值而用绑定呢(其实你也可以称之为赋值,但是绑定的含义更清晰准确)?这里就涉及 Rust 最核心的原则——所有权,简单来讲,任何内存对象都是有主人的,而且一般情况下完全属于它的主人,绑定就是把这个对象绑定给一个变量,让这个变量成为它的主人(聪明的读者应该能猜到,在这种情况下,该对象之前的主人就会丧失对该对象的所有权),像极了我们的现实世界,不是吗?

那为什么要引进“所有权”这个新的概念呢?请稍安勿躁,时机一旦成熟,我们就回来继续讨论这个话题。

变量可变性

Rust 的变量在默认情况下是不可变的。当然你可以通过 mut 关键字让变量变为可变的,让设计更灵活。

如果变量 a 不可变,那么一旦为它绑定值,就不能再修改 a。举个例子,在我们的工程目录下使用 cargo new variables 新建一个项目,叫做 variables。

然后在新建的 variables 目录下,编辑 src/main.rs,改为下面代码:

fn main() {
    let x = 5;
    println!("The value of x is: {}", x);
    x = 6;
    println!("The value of x is: {}", x);
}

保存文件,再使用 cargo run 运行它,迎面而来的是一条错误提示:

$ cargo run
   Compiling variables v0.1.0 (file:///projects/variables)
error[E0384]: cannot assign twice to immutable variable `x`
 --> src/main.rs:4:5
  |
2 |     let x = 5;
  |         -
  |         |
  |         first assignment to `x`
  |         help: consider making this binding mutable: `mut x`
3 |     println!("The value of x is: {}", x);
4 |     x = 6;
  |     ^^^^^ cannot assign twice to immutable variable

error: aborting due to previous error

具体的错误原因为 cannot assign twice to immutable variable x(无法对不可变的变量重复赋值),因为我们想为不可变的 x 变量再次赋值。

在 Rust 中,可变性很简单,只要在变量名前加一个 mut 即可,而且这种显示声明方式还会给后来人传达这样的信息:嗯,这个变量在后面代码部分会发生改变。

为了让变量为可变,将 src/main.rs 改为以下内容:

fn main() {
    let mut x = 5;
    println!("The value of x is: {}", x);
    x = 6;
    println!("The value of x is: {}", x);
}

运行程序将得到下面结果:

$ cargo run
   Compiling variables v0.1.0 (file:///projects/variables)
    Finished dev [unoptimized + debuginfo] target(s) in 0.30s
     Running `target/debug/variables`
The value of x is: 5
The value of x is: 6

选择可变还是不可变,更多的还是取决于你的使用场景,例如不可变可以带来安全性,但是丧失了灵活性和性能(如果你要改变,就要重新创建一个新的变量,这里涉及到内存对象的再分配)。而可变变量最大的好处就是使用上的灵活性和性能上的提升。

例如,在使用大型数据结构或者热点代码路径(被大量频繁调用)的情形下,在同一内存位置更新实例可能比复制并返回新分配的实例要更快。使用较小的数据结构时,通常创建新的实例并以更具函数式的风格来编写程序,可能会更容易理解,所以值得以较低的性能开销来确保代码清晰。

使用下划线开头忽略未使用的变量

如果你创建了一个变量却不在任何地方中使用它,Rust 通常会给你一个警告,因为这可能会是个 Bug。但是有时创建一个不会被使用的变量是有用的,比如正在设计原型或刚刚开始一个项目。这时你希望告诉 Rust 不要警告未使用的变量,为此可以用下划线作为变量名的开头

fn main() {
    let _x = 5;
    let y = 10;
}

使用 cargo run 运行下试试:

warning: unused variable: `y`
 --> src/main.rs:3:9
  |
3 |     let y = 10;
  |         ^ help: 如果 y 故意不被使用,请添加一个下划线前缀: `_y`
  |
  = note: `#[warn(unused_variables)]` on by default

可以看到,两个变量都只是声明,没有使用,但编译器却只给出了 y 未被使用的警告,充分说明了 _ 变量名前缀在这里发挥的作用。

变量解构

let 表达式不仅仅用于变量的绑定,还能进行复杂变量的解构:从一个相对复杂的变量中,匹配出该变量的一部分内容:

fn main() {
    let (a, mut b) = (true, false);
    // a = true,不可变; b = false,可变
    println!("a = {:?}, b = {:?}", a, b);

    b = true;
    assert_eq!(a, b);
}

解构式赋值

在 Rust 1.59 版本后,可以在赋值语句中的左式中使用元祖、切片和结构体模式了。

#[allow(dead_code)]
struct Struct {
    e: i32,
    f: i32,
}

fn main() {
    let (a, b, c, d, e): (i32, i32, i32, i32, i32);
    (a, b) = (1, 2);
    // _ 代表匹配一个值,但是我们不关心具体的值是什么,因为没有使用一个变量名而是 _
    [c, .., d, _] = [1, 2, 3, 4, 5];
    Struct { e, .. } = Struct { e: 5, f: 6 };

    assert_eq!([1, 2, 1, 4, 5], [a, b, c, d, e])
}

这种使用方式跟之前的 let 保持了一致性,但是 let 会重新绑定,而这里仅仅对之前绑定的变量进行再赋值。

需要注意的是,使用 += 的赋值语句还不支持解构式赋值。

变量和常量之间的差异

常量与不可变变量一样,常量也是绑定到一个常量名且不允许更改的值,但是常量和变量之间存在一些差异:

  • 常量不允许使用 mut常量不仅默认不可变,而且自始至终不可变。因为常量在编译完成后,就已经确定了它的值。
  • 常量使用 const 关键字而不是 let 关键字来声明,并且值的类型必须标注。

下面是一个常量声明的例子,其常量名为 MAX_POINTS,值为 100,000。(Rust 常量的命名约定是全部字母都大写,并使用下划线分隔单词,另外对数字字面量可插入下划线以提高可读性)

const MAX_POINTS: u32 = 100_000;

常量可以在任意作用域内声明,包括全局作用域,在声明的作用域内,常量在程序运行的整个过程中都有效。对于需要在多处代码共享一个不可变的值时非常有用,例如游戏中允许玩家赚取的最大点数或光速。

变量遮蔽

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);
}

这个程序首先将数值 5 绑定到 x,然后通过重复使用 let x = 来遮蔽之前的 x,并取原来的值加上 1,所以 x 的值变成了 6。第三个 let 语句同样遮蔽前面的 x,取之前的值并乘上 2,得到的 x 最终值为 12。当运行此程序,将输出以下内容:

$ cargo run
   Compiling variables v0.1.0 (file:///projects/variables)
   ...
The value of x in the inner scope is: 12
The value of x is: 6

这和 mut 变量的使用是不同的,第二个 let 生成了完全不同的新变量,两个变量只是恰好拥有同样的名称,涉及一次内存对象的再分配 ,而 mut 声明的变量,可以修改同一个内存地址上的值,并不会发生内存对象的再分配,性能要更好。

变量遮蔽的用处在于,如果你在某个作用域内无需再使用之前的变量(在被遮蔽后,无法再访问到之前的同名变量),就可以重复的使用变量名字,而不用绞尽脑汁去想更多的名字。

例如,假设有一个程序要统计一个空格字符串的空格数量:

// 字符串类型
let spaces = "   ";
// usize 数值类型
let spaces = spaces.len();

这种结构是允许的,因为第一个 spaces 变量是一个字符串类型,第二个 spaces 变量是一个全新的变量且和第一个具有相同的变量名,且是一个数值类型。所以变量遮蔽可以帮我们节省些脑细胞,不用去想如 spaces_strspaces_num 此类的变量名;相反我们可以重复使用更简单的 spaces 变量名。如果你不用 let :

let mut spaces = "   ";
spaces = spaces.len();

运行一下,你就会发现编译器报错:

$ cargo run
   Compiling variables v0.1.0 (file:///projects/variables)
error[E0308]: mismatched types
 --> src/main.rs:3:14
  |
3 |     spaces = spaces.len();
  |              ^^^^^^^^^^^^ expected `&str`, found `usize`

error: aborting due to previous error

显然,Rust 对类型的要求很严格,不允许将整数类型 usize 赋值给 字符串类型usize 是一种 CPU 相关的整数类型。

命名规范

基本的 Rust 命名规范在 RFC 430 中有描述。

通常,对于 type-level 的构造 Rust 倾向于使用驼峰命名法,而对于 value-level 的构造使用蛇形命名法。详情如下:

条目 惯例
包 Crates unclear
模块 Modules snake_case
类型 Types UpperCamelCase
特征 Traits UpperCamelCase
枚举 Enumerations UpperCamelCase
结构体 Structs UpperCamelCase
函数 Functions snake_case
方法 Methods snake_case
通用构造器 General constructors new or with_more_details
转换构造器 Conversion constructors from_some_other_type
宏 Macros snake_case!
局部变量 Local variables snake_case
静态类型 Statics SCREAMING_SNAKE_CASE
常量 Constants SCREAMING_SNAKE_CASE
类型参数 Type parameters UpperCamelCase,通常使用一个大写字母: T
生命周期 Lifetimes 通常使用小写字母:'a'de'src
Features unclear but see C-FEATURE

对于驼峰命名法,复合词的缩略形式我们认为是一个单独的词语,所以只对首字母进行大写:使用 Uuid 而不是 UUIDUsize 而不是 USizeStdin 而不是 StdIn

对于蛇形命名法,缩略词用全小写:is_xid_start

对于蛇形命名法(包括全大写的 SCREAMING_SNAKE_CASE),除了最后一部分,其它部分的词语都不能由单个字母组成: btree_map 而不是 b_tree_mapPI_2 而不是 PI2.

包名不应该使用 -rs 或者 -rust 作为后缀,因为每一个包都是 Rust 写的,因此这种多余的注释其实没有任何意义。

特征命名

特征的名称应该使用动词,而不是形容词或者名词,例如 PrintDraw 明显好于 PrintableDrawable

类型转换要遵守 as\_to\_into\_ 命名惯例(C-CONV)

类型转换应该通过方法调用的方式实现,其中的前缀规则如下:

方法前缀 性能开销 所有权改变
as_ Free borrowed -> borrowed
to_ Expensive borrowed -> borrowedborrowed -> owned (non-Copy types)owned -> owned (Copy types)
into_ Variable owned -> owned (non-Copy types)

例如:

  • str::as\_bytes()str 变成 UTF-8 字节数组,性能开销是 0。输入是一个借用的 &str,输出也是一个借用的 &str
  • Path::to\_str 会执行一次昂贵的 UTF-8 字节数组检查,输入和输出都是借用的。对于这种情况,如果把方法命名为 as_str 是不正确的,因为这个方法的开销还挺大
  • str::to\_lowercase() 在调用过程中会遍历字符串的字符,且可能会分配新的内存对象。输入是一个借用的 str,输出是一个有独立所有权的 String
  • String::into\_bytes() 返回 String 底层的 Vec<u8> 数组,转换本身是零消耗的。该方法获取 String 的所有权,然后返回一个新的有独立所有权的 Vec<u8>

当一个单独的值被某个类型所包装时,访问该类型的内部值应通过 into_inner() 方法来访问。例如将一个缓冲区值包装为 BufReader 类型,还有 GzDecoderAtomicBool 等,都是这种类型。

如果 mut 限定符在返回类型中出现,那么在命名上也应该体现出来。例如,Vec::as\_mut\_slice 就说明它返回了一个 mut 切片,在这种情况下 as_mut_sliceas_slice_mut 更适合。

// 返回类型是一个 `mut` 切片
fn as_mut_slice(&mut self) -> &mut [T];

标准库中的一些例子

读访问器(Getter)的名称遵循 Rust 的命名规范(C-GETTER)

除了少数例外,在 Rust代码中 get 前缀不用于 Getter。

pub struct S {
    first: First,
    second: Second,
}
    
impl S {
    // 而不是 get_first
    pub fn first(&self) -> &First {
        &self.first
    }
    
    // 而不是 get_first_mut,get_mut_first,or mut_first
    pub fn first_mut(&mut self) -> &mut First {
        &mut self.first
    }
}

至于上文提到的少数例外,如下:当有且仅有一个值能被 Getter 所获取时,才使用 get 前缀。例如,Cell::get 能直接访问到 Cell 中的内容。

有些 Getter 会在过程中执行运行时检查,那么我们就可以考虑添加 _unchecked Getter 函数,这个函数虽然不安全,但是往往具有更高的性能。 典型的例子如下:

fn get(&self, index: K) -> Option<&V>;
fn get_mut(&mut self, index: K) -> Option<&mut V>;
unsafe fn get_unchecked(&self, index: K) -> &V;
unsafe fn get_unchecked_mut(&mut self, index: K) -> &mut V;

标准库示例

一个集合上的方法,如果返回迭代器,需遵循命名规则:iteriter\_mutinto\_iter (C-ITER)

fn iter(&self) -> Iter             // Iter implements Iterator<Item = &U>
fn iter_mut(&mut self) -> IterMut  // IterMut implements Iterator<Item = &mut U>
fn into_iter(self) -> IntoIter     // IntoIter implements Iterator<Item = U>

上面的规则适用于同构性的数据集合。与之相反,str 类型是一个 UTF-8 字节数组切片,与同构性集合有一点微妙的差别,它可以认为是字节集合,也可以认为是字符集合,因此它提供了 str::bytes 去遍历字节,还有 str::chars 去遍历字符,而并没有直接定义 iter 等方法。

上述规则只适用于方法,并不适用于函数。例如 url 包的 percent\_encode 函数返回一个迭代器用于遍历百分比编码(Percent encoding)的字符串片段. 在这种情况下,使用 iter/iter_mut/into_iter 诸如此类的函数命名无法表达任何具体的含义。

标准库示例

迭代器的类型应该与产生它的方法名相匹配(C-ITER-TY)

例如形如 into_iter() 的方法应该返回一个 IntoIter 类型,与之相似,其它任何返回迭代器的方法也应该遵循这种命名惯例。

上述规则主要应用于方法,但是经常对于函数也适用。例如上文提到的 url 包中的 percent\_encode 函数,返回了一个 PercentEncode 类型。

特别是,当这些类型跟包名前缀一起使用时,将具备非常清晰的含义,例如 vec::IntoIter

标准库示例

Cargo Feature 的名称不应该包含占位词(C-FEATURE)

不要在 Cargo feature 中包含无法传达任何意义的词,例如 use-abcwith-abc,直接命名为 abc 即可。

一个典型的例子就是:一个包对标准库有可选性的依赖。标准的写法如下:

# 在 Cargo.toml 中
    
[features]
default = ["std"]
std = []
// 在我们自定义的 lib.rs 中
    
#![cfg_attr(not(feature = "std"), no_std)]

除了 std 之外,不要使用任何 ust-std 或者 with-std 等自以为很有创造性的名称。

命名要使用一致性的词序(C-WORD-ORDER)

这是一些标准库中的错误类型:

它们都使用了 谓语-宾语-错误 的词序,如果我们想要表达一个网络地址无法分析的错误,由于词序一致性的原则,命名应该如下 ParseAddrError,而不是 AddrParseError

词序和个人习惯有很大关系,想要注意的是,你可以选择合适的词序,但是要在包的范畴内保持一致性,就如标准库中的包一样。

附录 A:关键字

下面的列表包含 Rust 中正在使用或者以后会用到的关键字。因此,这些关键字不能被用作标识符(除了原生标识符),包括函数、变量、参数、结构体字段、模块、包、常量、宏、静态值、属性、类型、特征或生命周期。

目前正在使用的关键字

如下关键字目前有对应其描述的功能。

  • as - 强制类型转换,或useextern crate包和模块引入语句中的重命名
  • break - 立刻退出循环
  • const - 定义常量或原生常量指针(constant raw pointer)
  • continue - 继续进入下一次循环迭代
  • crate - 链接外部包
  • dyn - 动态分发特征对象
  • else - 作为 ifif let 控制流结构的 fallback
  • enum - 定义一个枚举类型
  • extern - 链接一个外部包,或者一个宏变量(该变量定义在另外一个包中)
  • false - 布尔值 false
  • fn - 定义一个函数或 函数指针类型 (function pointer type)
  • for - 遍历一个迭代器或实现一个 trait 或者指定一个更高级的生命周期
  • if - 基于条件表达式的结果来执行相应的分支
  • impl - 为结构体或者特征实现具体功能
  • in - for 循环语法的一部分
  • let - 绑定一个变量
  • loop - 无条件循环
  • match - 模式匹配
  • mod - 定义一个模块
  • move - 使闭包获取其所捕获项的所有权
  • mut - 在引用、裸指针或模式绑定中使用,表明变量是可变的
  • pub - 表示结构体字段、impl 块或模块的公共可见性
  • ref - 通过引用绑定
  • return - 从函数中返回
  • Self - 实现特征类型的类型别名
  • self - 表示方法本身或当前模块
  • static - 表示全局变量或在整个程序执行期间保持其生命周期
  • struct - 定义一个结构体
  • super - 表示当前模块的父模块
  • trait - 定义一个特征
  • true - 布尔值 true
  • type - 定义一个类型别名或关联类型
  • unsafe - 表示不安全的代码、函数、特征或实现
  • use - 在当前代码范围内(模块或者花括号对)引入外部的包、模块等
  • where - 表示一个约束类型的从句
  • while - 基于一个表达式的结果判断是否继续循环

保留做将来使用的关键字

如下关键字没有任何功能,不过由 Rust 保留以备将来的应用。

  • abstract
  • async
  • await
  • become
  • box
  • do
  • final
  • macro
  • override
  • priv
  • try
  • typeof
  • unsized
  • virtual
  • yield

原生标识符

原生标识符(Raw identifiers)允许你使用通常不能使用的关键字,其带有 r# 前缀。

例如,match 是关键字。如果尝试编译如下使用 match 作为名字的函数:

fn match(needle: &str, haystack: &str) -> bool {
    haystack.contains(needle)
}

会得到这个错误:

error: expected identifier, found keyword `match`
 --> src/main.rs:4:4
  |
4 | fn match(needle: &str, haystack: &str) -> bool {
  |    ^^^^^ expected identifier, found keyword

该错误表示你不能将关键字 match 用作函数标识符。你可以使用原生标识符将 match 作为函数名称使用:

文件名: src/main.rs

fn r#match(needle: &str, haystack: &str) -> bool {
    haystack.contains(needle)
}
    
fn main() {
    assert!(r#match("foo", "foobar"));
}

此代码编译没有任何错误。注意 r# 前缀需同时用于函数名定义和 main 函数中的调用。

原生标识符允许使用你选择的任何单词作为标识符,即使该单词恰好是保留关键字。 此外,原生标识符允许你使用其它 Rust 版本编写的库。比如,try 在 Rust 2015 edition 中不是关键字,却在 Rust 2018 edition 是关键字。所以如果用 2015 edition 编写的库中带有 try 函数,在 2018 edition 中调用时就需要使用原始标识符语法,在这里是 r#try


  1. Rust 命名规范

  2. Rust 关键字

Copyright: 采用 知识共享署名4.0 国际许可协议进行许可

Links: https://cangmang.xyz/articles/1750566371700