基于 C++ 的 Rust 教程(一):Rust 基本信息、基本程序框架、数据与变量

· · 科技·工程

:::epigraph[——Rust] 一门赋予每个人 \ 构建可靠且高效软件能力的语言。 :::

这是一篇帮助你在有 C++ 基础的情况下学会 Rust 基本使用的教程。

本文是此系列的第一篇

本篇内容

在这一篇,你将学习到:Rust 基本信息、基本程序框架、数据与变量。

为什么是 Rust?

Rust 并不在 OI 官方支持语言范围之内,但是学习 Rust 的好处还是挺多的:

  1. 如果你未来想要做计算机方面的工作,Rust 是一个极好的新兴编程语言。Rust 的工作岗位在不断增多,因为大公司在不断用 Rust 重构之前使用 C/C++ 写的部分代码。
  2. Rust 是一个能够在编译期就尽量避免内存安全和数据竞争问题的语言。因此,如果你想要写一些自己的程序项目,Rust 能够尽量保证你的程序正确。
  3. Rust 采用的是一种全新的编程方式:所有权系统模式。这种编程模式对思维的考验非常大,因此能够很好地锻炼我们的思维能力和阅读代码分析问题的能力。
  4. Rust 的应用范围极其广泛,可以编写控制台应用、GUI 应用、Web App、嵌入式甚至是操作系统。

等到 Rust 在国内的传播度进一步上升以及其学习成本进一步下降,我们有理由期待 OI 会把 Rust 纳入支持语言之一,虽然可能要好几年之后。

本文的目标读者

本文的目标读者是能够熟练使用 C++ 的程序员。这其中包括 oier(信息奥赛选手)、刚接触编程的学生或使用 C++ 进行基本开发的程序员。

本文中与深层编程知识相关的地方不会讲得很深入。如果你是专业的程序员,或者想学习更深入的、专业的 Rust 语言,建议直接去阅读 Rust 中文教程之经典《Rust 圣经》。

Rust 的特点

Rust 采用特殊的所有权系统来保障内存数据安全和避免数据竞争。

Rust 属于编译语言,而且采用 LLVM 作为其后端(类似 C/C++ 编译器 Clang),因此 Rust 的效率极高,在大多数情况下与 C/C++ 齐平甚至更高。当然,在极其底层的硬件操作上,可能略逊于 C/C++。

Rust 能够与 C/C++ 代码无缝衔接,但是由于 Rust 是保障内存安全的语言,而 C/C++ 的内存安全性要程序员自身保障,所以在 Rust 中使用 C/C++ 的内容时,必须使用 Rust 中的非安全代码来强制编译器放宽内存安全的检查。C/C++ 代码中使用 Rust 提供的函数、接口等则限制较少。

本文的结构

本文的行文结构借鉴了中文 Rust 教程的经典《Rust 语言圣经》。下面是本文大概的行文框架:

首先介绍 Rust 的安装、使用等方面的简单内容,随后尝试编写第一个程序——HelloWorld 程序。

随后,会介绍 Rust 的基本数据类型(以及简单的字符串)和数据运算,以及变量的相关内容。这部分是为了后面的所有权系统做铺垫的。

再然后,我们会立马进入所有权系统的介绍,尽早将这个 Rust 的核心讲述完。这一点是参考了《Rust 语言圣经》的。如果所有权系统的介绍拖得太晚,那么其他语言相关的内容就会因为所有权系统的牵制而无法很好地进行讲述。

最后,我们再讲一讲 Rust 的其他内容,如控制流、结构体、枚举量等。我们只讲最简单的内容,更高级和复杂的功能留给感兴趣的读者。当然,如果读者已经读到这了,相信也是对 Rust 比较感兴趣的吧。

简单介绍 Rust 的所有权系统

Rust 是如何在编译时就保障数据安全的呢?关键就在于 Rust 首创的所有权系统。

下面是 Rust 所有权系统规则的简单介绍。暂时看不懂是没关系的,在后面的教程中会以代码的形式进行讲述。所有权系统是 Rust 学习最难的地方,因此需要一些时间来理解。

  1. 变量默认是不可变的,当然你可以让它成为可变的。
  2. 数据和使用者是两个概念。使用者分为所有者(拥有和完全掌控该数据)和借用者(只能使用该数据)。
  3. 一个数据只能有一个所有者。所有者可以完全控制数据,同时所有者能够释放(销毁)数据。也就是说,数据的生命周期由所有者决定。
  4. 其他地方想要使用数据,必须由所有者主动提供借用类型或者让出所有权。也就是说,其他地方使用该数据的方式是借用该数据,而不是拥有数据;或者是让原所有者转移所有权。
  5. 借用分为两种:不可变借用和可变借用。不可变变量只能产生不可变借用,可变变量则两者均可。
  6. 持有不可变借用的,只能读取数据,不能修改数据。持有可变借用的,可以读取或修改数据。
  7. 一个数据可以有多个不可变借用(多个地方同时读取一个数据是安全的),但只能同时有一个可变借用(多个地方同时修改一个数据是不安全的);不可变借用和可变借用不能同时存在(一个在写一个在读是不安全的:不可变借用期望数据不会改变)。
  8. 所有的数据修改的生命周期不能重合。
  9. 上面提到的“同时”指的就是所有者或几个借用者的生命周期重合。例如,如果一个可变借用者的生命周期和一个不可变借用者的生命周期有重合部分,则认为它们两个同时存在。
  10. 借用者的生命周期必须小于所有者的生命周期,因为借用一个过期的数据是不安全的。

后面我们将用代码来详细讲述上面的概念。现在,让我们从最基础的地方开始。

准备工作

安装 Rust

安装和配置 Rust 可以在网络上找到相关内容,本文不再撰述。

如果你只是想尝试 Rust,暂时还不想在本地安装 Rust 编译器,可以参考下一节使用官方提供的在线编辑器。

编写 Rust 代码并编译运行

Rust 代码编辑器或 IDE

一个好的编辑器(或 IDE,集成开发环境)能够大大提升编程效率。以下是一些推荐的常用的 Rust 代码编辑器:

:::info[提示:关于 Rust Playground]{open} Rust Playground 还有一个好处,就是能够快速设置基本的编译选项(调试模式还是发布模式、运行时错误时是否显示堆栈等)。此外,Rust Playground 能够很方便地查看编译之后的 ASM(汇编代码)、LLVM IR(LLVM 中间代码)、HIR(Rust 高级中间代码)、MIR(Rust 中级中间代码)、WASM(WebAssembly,网络汇编)。如果你对 Rust 的实现原理感兴趣,或者是想要学习相关的知识,这很有帮助。 :::

编译运行 Rust 代码

安装好本地的 Rust 之后,可以使用 rustc 编译器进行编译。如果你要以项目为单位进行开发(VSCode、RustRover 默认以项目为单位编码),则可以使用 Rust 官方提供的包管理器 cargo。具体的用法不再赘述,你可以自行搜索。

梦开始的地方:你好,世界!

每个人第一次接触一个编程语言的第一个程序一般就是在屏幕上输出 Hello, World!。这是编程界悠久的传统。

下面是 Rust 的 Hello, World! 程序:

fn main() {
    println!("Hello, World!");
}

编译并运行(参考上一节)后,我们可以看到终端输出:

Hello, World!

恭喜你,成功运行了第一个 Rust 程序!

HelloWorld 程序解释

主函数:程序基本框架

我们可以看到,上面的程序的默认框架为:

fn main() {
    // something...
}

与 C++ 类似,Rust 程序需要一个主函数 main 作为程序的入口点。与 C++ 不同的是,Rust 的主函数不需要返回值。也就是说,main 函数的返回类型是 ()。这个在 Rust 中叫做单元类型,与 C++ 的 void 类似,不过单元类型也可以有值,叫做单元值(())。

Rust 的函数使用 fn 关键字描述。返回类型放在参数列表之后,用箭头区分。上面的 main 函数完整写上返回类型为:

fn main() -> () {
    // something...
}

如果不写返回类型,则默认返回单元类型。

标准输出 println!

看到主函数中间的代码:

println!("Hello, World!");

对于有 C++ 经验的我们,这很像函数调用。但是为什么会有一个冒号 !

我们去掉冒号再次尝试编译:

fn main() {
    println("Hello, World!");
}

会看到以下报错信息:

:::error[报错信息]

error[E0423]: expected function, found macro `println`
 --> src/main.rs:2:5
  |
2 |     println("Hello, World!");
  |     ^^^^^^^ not a function
  |
help: use `!` to invoke the macro
  |
2 |     println!("Hello, World!");
  |            +

For more information about this error, try `rustc --explain E0423`.

:::

:::info[提示:友好的报错信息] 顺便提一嘴,Rust 相对 C++ 的另一个优势就是友好的报错信息。等你熟悉了 Rust,你会发现你自己再也受不了 C++ 乱七八糟的报错信息了。 :::

这里就引出了 Rust 的第一个概念:。Rust 的宏与 C++ 的宏类似,做的都是代码的文本替换。但是 Rust 的宏更安全:它是基于编译器内部语法树的文本替换。关于宏的概念,我们后面会再讲解。现在你只需要知道,调用一个宏需要加上冒号 !

另外提一嘴,调用宏其实不一定非得要用小括号 (),用中括号 [] 或大括号 {} 都是可以的。只不过习惯上,我们使用看起来像函数调用的宏时,使用小括号。

你可以尝试以下代码:

fn main() {
    println!("Hello, World!");
    println!["Hello, World!"];
    println!{
        "Hello, World!"
    };
}

编译并运行,得到输出:

Hello, World!
Hello, World!
Hello, World!

需要注意的是,Rust 的语句与 C++ 一样,需要在结尾写上分号 ;

注释

Rust 的单行注释与 C++ 非常类似,使用两个斜杠 // 表示注释的开始。

// 这是一串注释

多行注释同样类似:

/* 这是一串多行注释

它可以跨很多行

这是最后一行 */

同样地,这个注释可以嵌入代码内部:

println! /* 这是一个宏 */ ("Hello, World!");

除此之外,Rust 还有文档注释:

/// 这是单行文档注释。

/**
这是多行文档注释。
这是第二个行。
*/

Rust 中,文档注释通常用于结构体、特征、函数等之前,用于文档说明。例如:

/// 这个结构体什么也没有
struct EmptyStruct {}

现在,通过 Rust 生成文档的工具,你会看到 EmptyStruct 一项,并且说明为“这个结构体什么也没有”。如果你的 IDE 有提示功能(如 RustRover),那么当你把鼠标悬停在任何一个 EmptyStruct 上时,就会看到 IDE 显示文档注释“这个结构体什么也没有”。

在大型项目中,文档注释具有重大的作用。

不换行的标准输出

类似 println! 宏,print! 宏也可以输出内容到标准输出。不同的是,后者不会在后面自动添加换行符。尝试运行下面的程序:

fn main() {
    print!("Hello, ");
    println!("World!");
}

输出为:

Hello, World!

print! 宏其实与 C++ 中不使用 '\n'std::endl 的标准输出流 std::cout 类似。

但是要注意的是,只要不输出换行符,Rust 就不会刷新标准输出。这意味着,如果你想要用不换行的 print! 宏来输出一行提示语,然后进行标准输入。那么用户将看不到提示语(因为没有换行),而用户输入完毕按下 Enter 键后,用户才能看到提示语。但在 C++ 中,由于标准输入流 std::cin 绑定了标准输出流 std::cout,所以进行标准输入时会强制刷新标准输出。在 Rust 中,这个问题的解决方案是手动进行刷新,我们以后再讲。

标准输入(概览)

Rust 的标准输入比较复杂,我们需要学习完其他概念之后才能学习标准输入。这里给出一个标准输入的例子:

use std::io::stdin;

fn main() {
    println!("Please type your name: ");
    let input = stdin();
    let mut line = String::new();
    input.read_line(&mut line).unwrap();
    let name = line.trim();
    println!("Hello, {}!", name);
}

编译并运行它,在输出 Please type your name: 后,可以键入你的名字(可以包含空格),然后它就会向你问好。

:::warning[注意:在 Rust Playground 上输入] 如果你使用的是 Rust Playground 在线运行,在输出提示语之后,需要在下面的输入框中输入内容,然后按下“Send”按钮或者是 Enter 键,给程序发送标准输入。 :::

:::info[尝试] 你可以试试讲主函数第一行的 println! 改为 print! 试一试,来加深“不换行不刷新”的理解。 :::

数据与变量

标准输出格式化

为了方便查看程序的运行结果,我们会将一些内容输出到标准输出。

Rust 中的 print! 宏和 println! 宏都可以格式化。具体方法是:在第一个参数中写上格式化字符串,其中用 {} 表示要插入值的位置。随后跟上与 {} 的数量相同的参数,表示要被格式化的值。

例如:

println!("This is an integer: {}", 123);  // This is an integer: 123
println!("{} + {} = {}", 1, 2, 1 + 2);  // 1 + 2 = 3

它等价于下面的 C++ 代码:

std::cout << "This is an integer: " << 123 << std::endl;
std::cout << 1 << " + " << 2 << " = " << 1 + 2 << std::endl;

或者在 C++23 中的这个代码:

// 请包含此头文件。有些编译器需要开启一定选项
#include <print>
// 或者使用模块
import std;

std::println("This is an integer: {}", 123);
std::println("{} + {} = {}", 1, 2, 1 + 2);

除此之外,在格式化字符串中,你可以直接将变量写在花括号内。例如:

let var = 1;
println!("{var}");  // 1

但是注意,这种写法不支持表达式:

println!("{3 * (1 + 2)}");  // Error!

:::error[报错信息]

error: invalid format string: expected `}`, found `*`
 --> src/main.rs:2:18
  |
2 |     println!("{3 * (1 + 2)}");
  |               -  ^ expected `}` in format string
  |               |
  |               because of this opening brace
  |
  = note: if you intended to print `{`, you can escape it using `{{`

:::

既想要使用命名的格式化参数,又想要使用表达式,你可以这样写:

println!("{result}", result = 3 * (1 + 2));

这样有一个好处:在多个地方重用结果。对于带副作用的表达式来说,这一点非常有用。例如:

println!("The result is {result}. Repeat. The result is {result}.", result = 3 * (1 + 2));

想要输出花括号字符本身,你可以双写。

let var = 123;
println!("println!(\"{{var}}\") => {var}");  // println!("{var}") => 123

声明和使用变量

你可以使用 let 语句来声明一个变量:

let variable;

你可以不用指定该变量的类型,Rust 会根据后面的代码对 variable 的操作自动推导类型。

你可以在之后初始化它:

let variable;
variable = 1;  // 初始化 variable,同时编译器推导出 variable 的类型是整型。

或者在声明的时候直接初始化:

let variable = 1;

随后,你可以像在 C++ 中一样使用该变量:

let variable = 1;
let result = variable * 10 - 2;
println!("{result}");  // 8

但是要注意,使用 let 声明的变量默认是不可变变量。也就是说,在初始化之后,就不允许再更改它的值了:

let variable = 1;
variable = 2;  // Error!

:::error[报错信息]

error[E0384]: cannot assign twice to immutable variable `variable`
 --> src/main.rs:3:5
  |
2 |     let variable = 1;
  |         -------- first assignment to `variable`
3 |     variable = 2;
  |     ^^^^^^^^^^^^ cannot assign twice to immutable variable
  |
help: consider making this binding mutable
  |
2 |     let mut variable = 1;
  |         +++

For more information about this error, try `rustc --explain E0384`.

:::

:::success[一个问题] 是不是再一次感受到了 Rust 报错信息的优雅? :::

包括这个也是不允许的:

let variable;
variable = 1;  // OK 初始化
variable = 2;  // Error 二次赋值

要想解决这个问题,在报错信息中也给出了:写一个 mut 就行:

let mut variable = 1;  // 现在 variable = 1
variable = 2;  // 现在 variable = 2

其中 mut 就是“mutable(可变的)”的意思。

::::info[为什么默认不可变?] 因为 Rust 要严格保证数据的安全性。而保证安全的一个措施就是默认不可变。这样程序员必须考虑到那些应该要变化,哪些不应该变化。如果默认可变(类似 C++),那么可能会不小心把不该改变的数据更改掉。Rust 通过默认不可变来保证数据安全:只有有必要改变的变量才需要变。

例如,下面的这个代码会导致警告:

let mut variable = 1;  // 定义为可变,但是初始化后从来没有更改过数据。换句话说,这个变量没有必要声明成可变的
println!("{variable}");

:::warning[警告信息]

warning: variable does not need to be mutable
 --> src/main.rs:2:9
  |
2 |     let mut variable = 1;
  |         ----^^^^^^^^
  |         |
  |         help: remove this `mut`
  |
  = note: `#[warn(unused_mut)]` on by default

:::

包括在一些比较智能的现代化 C++ IDE 中(如 CLion),它们也会提示你为不需要可变的变量添加 const。例如:

const int variable = 1;
std::cout << variable << std::endl;

不可变还有一个优点:方便编译器优化。所以,在写 C++ 代码时,也可以多多使用 const 来限定不可变变量(在 C++ 中叫做常量。但 C++ 常量的值不一定是编译时常量,可以是运行时的值,只是不允许二次改变而已)。 ::::

基本数据类型

与 C++ 一样,Rust 也有基本数据类型。下表整理了 Rust 的基本数据类型:

需要注意的是,C++ 的整数类型实际上不是跨平台的。或者说,C++ 的整数类型宽度不是固定的。例如,在某些平台上,int 是 32 位的,但在另一些平台上,int 是 16 位的;在某些平台上,long 是 32 位的,但在另一些平台上,long 是 64 位的。因此,下面的表格中“C++ 同等类型”中使用的是 Linux x64 中常见的整数位宽。

Rust 基本数据类型 描述 C++ 同等类型 C++ 固定宽度类型
u8 8 位无符号整数 unsigned char std::uint8_t
i8 8 位有符号整数 signed char std::int8_t
u16 16 位无符号整数 unsigned short std::uint16_t
i16 16 位有符号整数 short std::int16_t
u32 32 位无符号整数 unsigned std::uint32_t
i32 32 位有符号整数 int std::int32_t
u64 64 位无符号整数 unsigned long std::uint64_t
i64 64 位有符号整数 long std::int64_t
u128 128 位无符号整数 <
i128 128 位有符号整数 <
f32 单精度浮点型 float <
f64 双精度浮点型 double <
bool 布尔型 bool <
usize 无符号大小类型(平台相关) std::size_t
isize 有符号大小类型(平台相关) std::ssize_t
char Unicode 字符(32 位) char32_t <

:::info[提示:关于 usizeisize] usizeisize 是用于表示线性数据结构如数组的大小的类型,和计算机系统的位宽相关。在 32 位计算机上,它们分别是 u32i32;在 64 位计算机上,它们分别是 u64i64。因为不同位宽的计算机的数据处理块的大小不同,所以使用合适的位宽表示数据大小是非常合理的。 :::

使用这些类型也非常简单。我们以声明变量的 let 语句为例:

let a = 1;  // 推导为 a: i32
let b: i64 = 2;  // b: i64

let 语句的变量名称后面跟上一个冒号并写上类型,可以显式指定变量的类型。

在 Rust 中,类型是非常严格的,不允许隐式类型转换(字面量除外)。例如,这个代码会报错:

let a: i32 = 1;
let b: i64 = 2;
let c = a + b;

:::error[报错信息]

error[E0308]: mismatched types
 --> src/main.rs:4:17
  |
4 |     let c = a + b;
  |                 ^ expected `i32`, found `i64`

error[E0277]: cannot add `i64` to `i32`
 --> src/main.rs:4:15
  |
4 |     let c = a + b;
  |               ^ no implementation for `i32 + i64`
  |
  = help: the trait `Add<i64>` is not implemented for `i32`
  = help: the following other types implement trait `Add<Rhs>`:
            `&i32` implements `Add<i32>`
            `&i32` implements `Add`
            `i32` implements `Add<&i32>`
            `i32` implements `Add`

Some errors have detailed explanations: E0277, E0308.
For more information about an error, try `rustc --explain E0277`.

:::

这表示 Rust 仅仅能够给两个相同类型的数值执行操作。我们可以使用 as 来进行显式类型转换。

let a: i32 = 1;
let b: i64 = 2;
let c = a as i64 + b;

这样这个代码就没有问题了。

查看数据的类型

你可以使用以下方法来获得某个数据的类型。有些代码我们还没有涉及到,因此你只需要复制这一段代码,然后直接使用即可:

fn get_type_name<T>(_: &T) -> &str {
    std::any::type_name::<T>()
}

fn main() {
    let a: i32 = 1;
    println!("{type_name}", type_name = get_type_name(&a));  // 注意带 & 号
    // 输出:i32
}

get_type_name 接受一个参数(但是不使用它),然后返回一个字符串表示该数据的类型。请加上 & 符号表示借用(我们以后会讲)。

自动类型推导

需要注意的是,没有明显类型的字面量默认都不是确定的类型。例如在下面这个代码中,变量 a 的类型实际上是不确定的:

let a = 1;

只不过后来 Rust 编译器发现,没有更多的代码可以推导 a 的类型了。于是 Rust 认为变量 a 是默认的整数类型——i32 类型。

再看一个例子:

let a = 1;
let b: i64 = a + 2;

此时,a 的类型就是 i64 了。为什么呢?要注意的是,字面量 1 是没有固定类型的,是不包括类型信息的。此时编译器不知道 a 到底是哪种整型。接下来,编译器遇到了变量 b。变量 b 显式给出了类型 i64,因此编译器认为:给 b 初始化的表达式 a + 2 也必须是 i64 的类型。根据严格的类型限制,必须是相同的两个类型运算得到同样的类型,编译器就知道了 a2 的类型也必须都是 i64。这样我们就推导出了 a 的类型应该是 i64

如果我们写成这样:

let a: i32 = 1;
let b: i64 = a + 2;  // Error!

就会导致错误。因为你已经规定了 ai32 类型,但编译器后来发现 a 需要是 i64 类型。冲突!所以报错。使用显式类型转换就可以了:

let a: i32 = 1;
let b: i64 = a as i64 + 2;

同样地,如果初始化的类型和变量声明的类型不一致,也会报错:

let a: i32 = 1 as i64;  // Error!

:::error[报错信息]

error[E0308]: mismatched types
 --> src/main.rs:6:18
  |
6 |     let a: i32 = 1 as i64;
  |            ---   ^^^^^^^^ expected `i32`, found `i64`
  |            |
  |            expected due to this
  |
help: you can convert an `i64` to an `i32` and panic if the converted value doesn't fit
  |
6 |     let a: i32 = (1 as i64).try_into().unwrap();
  |                  +        +++++++++++++++++++++

For more information about this error, try `rustc --explain E0308`.

:::

:::info[提示:报错信息中的帮助代码] 报错信息中给出的帮助代码 .try_into().unwrap() 是另一种类型转换方式。这一部分超过了本文的讨论范围,读者可以自行搜索学习。 :::

现在,你可以试一试解释一下下面的代码为什么不会报错,并尝试给出程序输出的内容。然后运行程序,查看是否符合自己的预期:

fn get_type_name<T>(_: &T) -> &str {
    std::any::type_name::<T>()
}

fn main() {
    let a = 1;
    let b: i64 = 2;
    let c = a + b;

    println!("a: {type_a} c: {type_c}", type_a = get_type_name(&a), type_c = get_type_name(&c));
}

字面量

字面量是数据在代码中的直接表示。

整型字面量与 C++ 类似,直接写上一个整数就行。此外,你可以通过 0b 前缀创建二进制字面量,0o 前缀创建八进制字面量,0x 前缀创建十六进制字面量。

let a = 101;
let b = 0b101;
let c = 0o101;
let d = 0x101;

println!("a = {a}, b = {b}, c = {c}, d = {d}");
// a = 101, b = 5, c = 65, d = 257

如果实际的数字和进制前缀不符,则会报错:

let a = 0b123;  // Error!

:::error[报错信息]

error: invalid digit for a base 2 literal
 --> src/main.rs:2:16
  |
2 |     let a = 0b123;
  |                ^

error: invalid digit for a base 2 literal
 --> src/main.rs:2:17
  |
2 |     let a = 0b123;
  |                 ^

:::

此外,你可以在数字中添加任意多的下划线 _ 来给数字分组。

let a = 1_000__000;
let b = 0x_12_34_56_78;
println!("a = {a}, b = {b}");  // a = 1000000, b = 305419896

注意,虽说不带类型的整数字面量的整数宽度是不确定的,但如果编译器无法推断出更多有关类型的信息,会把它默认看作 i32 整数。

let a = 12345;
println!("{type_name}", type_name = get_type_name(&a));  // i32

所以你的整数不能太大:

let a = 1000000000000;  // Error!

:::error[报错信息]

error: literal out of range for `i32`
 --> src/main.rs:2:13
  |
2 |     let a = 1000000000000;
  |             ^^^^^^^^^^^^^
  |
  = note: the literal `1000000000000` does not fit into the type `i32` whose range is `-2147483648..=2147483647`
  = help: consider using the type `i64` instead
  = note: `#[deny(overflowing_literals)]` on by default

:::

但是如果 Rust 能够推断出这是一种其他类型(如 i64),情况就不一样了:

let a = 1000000000000;
let b: i64 = a / 2;
println!("type of a: {type_name}", type_name = get_type_name(&a));  // type of a: i64
println!("b = {b}");  // b = 500000000000

由于 Rust 推断出 a 应该是 i64 类型,因此右边的整型字面量就是在有效范围之内的了。

你可以在一个整数后面添加类型名称来强制规定其类型:

let a = 1i8;
let b = 23_i16;
let c = 12345_67890_09876_54321________u128;
println!("type(a) = {type_name}, a = {a}", type_name = get_type_name(&a));  // type(a) = i8, a = 1
println!("type(b) = {type_name}, b = {b}", type_name = get_type_name(&b));  // type(b) = i16, b = 23
println!("type(c) = {type_name}, c = {c}", type_name = get_type_name(&c));  // type(c) = u128, c = 12345678900987654321

浮点数也和 C++ 高度类似。当然,单独的浮点数不包含类型信息,默认为 f64。浮点数后也可以添加类型名称来限定。

let a = 3.14;
let b = .1;
let c = 1.;
let d = 123_456.7_8_9;
let e = 1.2;
let f = 3.4_f32 + e;

println!("type(a) = {type_name}, a = {a}", type_name = get_type_name(&a));
println!("type(b) = {type_name}, b = {b}", type_name = get_type_name(&b));
println!("type(c) = {type_name}, c = {c}", type_name = get_type_name(&c));
println!("type(d) = {type_name}, d = {d}", type_name = get_type_name(&d));
println!("type(e) = {type_name}, e = {e}", type_name = get_type_name(&e));
println!("type(f) = {type_name}, f = {f}", type_name = get_type_name(&f));

:::success[代码输出]{open}

type(a) = f64, a = 3.14
type(b) = f64, b = 0.1
type(c) = f64, c = 1
type(d) = f64, d = 123456.789
type(e) = f32, e = 1.2
type(f) = f32, f = 4.6000004

:::

数据运算

运算符优先级

以下是 Rust 中所有运算符的优先级表格。数字越小的表示优先级越高,结合性越强。可能有些运算符你没见过,没关系,后面会慢慢讲到。

优先级 运算符
1 路径 ::、方法调用 object.method()、字段访问 .
2 函数调用 ()、数组索引 []、错误传递 ?
3 一元正号 +、一元负号 -、逻辑非和按位取反 !、解引用 *、借用 &、可变借用 &mut
4 类型转换 as
5 算数乘法 *、算数除法 /、算数模 %
6 算数加法和字符串拼接 +、算数减法 -
7 左位移 <<、右位移 >>
8 按位与 &
9 按位异或 ^
10 按位或 \|
11 相等 ==、不等 !=、小于 <、大于 >、小于等于 <=、大于等于 >=
12 逻辑与 &&
13 逻辑或 \|\|
14 左闭右开区间 ..、闭区间 ..=
15 赋值 =、自赋值 += -=

算数运算符

算数运算符与 C++ 基本一致。需要注意的是,和 C++ 一样,除法 / 的两边都是整数时,执行的是整除运算;两边都是浮点数时,执行的是浮点除法。但和 C++ 不同的是,Rust 要求运算符两边的类型一致(不允许隐式类型转换),所以不存在一边浮点数另一边是整数的情况。

位运算符

与 C++ 基本一致。

:::warning[警告]{open} 但是,在 Rust 中,按位取反和逻辑非都是 !,而在 C++ 中按位取反是 ~。 :::

逻辑运算符

与 C++ 基本一致。

关系运算符

与 C++ 基本一致。

全局变量

很遗憾地告诉你:

Rust 强烈不建议使用全局可变变量。

受到 Rust 所有权系统和 Rust 线程数据安全系统的约束,使用全局可变变量是不安全行为,将导致编译错误。不过你可以使用不可变的全局变量。

可以使用 static 关键字来创建全局变量。全局变量习惯上使用全大写的用下划线分割单词的命名法(即 Upper Snake Case 命名法)。全局变量不支持自动类型推导,需要显式标注。

下面是几个例子:

static NUMBER: i32 = 1234;
static VERSION: &str = "1.0.0";
static NAME: &'static str = "Program";  // 显式指出静态生命周期。

全局变量的初始值必须是编译期确定的常量。如果要运行时初始化,则需要使用延迟初始化技术。

全局常量也是类似的用法,只不过是使用 const 关键字。例如:

const NUMBER: i32 = 1234;
const VERSION: &str = "1.0.0";
const NAME: &'static str = "Program";  // 显式指出静态生命周期。

既然用法都一样,那么全局不可变变量和常量有什么区别呢?

第一点,就是全局变量是固定内存的,编译器会在可执行程序的某一段中放置全局变量。而常量是内联优化的,编译器会在编译时将常量数据内联到使用的地方去。

第二点,就是全局变量可以使用一些线程安全容器来实现内部可变性运行时操作,而常量是不可以的。

第三点,就是 static 后其实是可以加上 mut 的。Rust 虽然不建议你使用全局可变变量,但是也不禁止。只不过操作全局可变变量需要一点特殊的手段,而且编译器不对操作全局可变变量负责。

:::info[使用不安全代码操作全局可变变量] Rust 不禁止你使用全局可变变量,这意味着 Rust 确实给我们开了个后门去使用全局可变变量。像这样定义全局可变变量:

static mut NUMBER: i32 = 1;

然后呢,使用 unsafe 来标记不安全代码,在不安全代码块中操作全局可变变量。

let number = unsafe { NUMBER };  // 读取
unsafe {
    NUMBER = 2;  // 修改
}

当然,unsafe 代码就意味着——Rust 不对其中的代码进行安全检查,不对其中的安全性负责,所有安全保障工作由程序员自己做。因此,在写 Rust 程序的时候,能不用 unsafe 就不使用 unsafe。万一确实要使用,也要尽量最小化 unsafe 的范围,让编译器多做点安全检查工作。 :::

第二篇预告

在第二篇,你将学习到:基本所有权系统。

版权许可协议

This work is licensed under a Creative Commons Attribution-ShareAlike 4.0 International License.

本文章使用 CC BY-SA 4.0(Creative Commons Attribution-ShareAlike 4.0 International License,知识共享 署名-相同方式共享 4.0 国际许可协议)进行许可。

您可以自由地:

惟须遵守以下条件: