Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

2. 变量

2.1 简介

  计算机本质是一种数据处理工具,它通过算术运算、逻辑判断、状态转换等操作对数据进行处理,最终产生新的数据。变量则是程序中用于存储数据的容器,既可以保存待处理的原始数据,也可以保存数据处理过程中产生的中间结果和最终结果。在 Rust 中,定义变量的基本语法格式为:

#![allow(unused)]
fn main() {
let 变量名: 数据类型 = 变量值;
}
  • let:关键字,用于声明变量,告诉编译器变量从这里开始。
  • 变量名:容器的名字,用于描述数据的用途和访问其中存储的数。
  • 数据类型:容器可以存放的数据类型,并决定容器大小。
  • 变量值:容器存放的数据。
fn main() {

    // 定义一个变量,存储数学成绩,值为 90
    let math_score = 90;

    // 定义一个变量并指定数据类型为 u8,存储 Rust 成绩
    let rust_score: u8 = 100;

    // 定义一个变量,存储计算结果
    let sum_score = math_score + rust_score;

    // 打印总分
    println!("总分: {}", sum_score);
}
shell> cargo run
总分: 190

  Rust 中,大多数情况下可以省略数据类型标注,编译器会根据变量的初始值和上下文自动推断其类型。不过,当类型推断存在歧义,或者为了提升代码的可读性和可维护性时,可以通过显式类型标注来明确指定变量的类型。

  变量名由开发者自定义,但必须遵循 Rust 的命名规则:变量名只能由英文字母(区分大小写)、数字(0–9)以及下划线(_)组成,并且不能以数字开头。Rust 官方推荐使用小写字母命名变量,当变量名由多个单词组成时,通常使用下划线分隔单词,即采用 snake_case 命名风格。

2.2 变量值的本质

  计算机是一种电子设备,其一切都是电路,程序中的数据最终都需要通过这些电路进行表示和存储。在电路中,电流、电压、电感、电阻等物理量都具有大小,理论上可以利用这些物理量来表示数值。例如,可以使用 5 伏特(V)的电压表示数字 5。然而,由于实现复杂、稳定性和成本等方面的限制,现代计算机并未采用这些方式来表示和存储数据,而是使用电路的状态表示数据,只需要一个简单的开关即可实现,开关导通时表示数值 1,开关断开时表示数值 0。

  单个开关只有“导通”和“断开”两种状态,因此只能表示两个数值:0 和 1。如果需要表示更大的数值,只需要增加更多的开关即可。例如,两个开关可以形成四种不同的状态组合:开开(11)、开关(10)、关开(01)和关关(00)。每种状态组合都对应一个唯一的数值,因此可以表示 0~3 共 4 个不同的数值。以此类推,使用 n 个开关时,可以产生 ​ 种不同的状态组合,能够表示的数据范围也会随之扩大。在计算机中,每个开关的状态可以表示一个数据位,单位称为 Bit(比特,简称位)。

  因此,在计算机中,数据(变量值)本质一组开关状态的组合,不同的数据对应不同的开关状态组合。

2.3 数据类型的本质

  数据类型决定了变量能够存储的数据种类,例如整数、小数、文本等,同时也决定了用于存储数据的容器大小(即开关的数量),而容器大小进一步决定了数据的取值范围。Rust 中的数值类型提供了 8 位、16 位、32 位、64 位和 128 位不同大小的类型,用于表示不同范围和精度的数值。通过size_of::<数据类型>() 函数查看指定数据类型大小,返回结果以字节(Byte)为单位,1 Byte 等于 8 Bit。

fn main() {
    // 查看 u8 数据类型大小
    let size_of_u8  = size_of::<u8>();
    println!("u8 类型大小: {}", size_of_u8);

    // 查看 u16 数据类型大小
    let size_of_u16 = size_of::<u16>();
    println!("u16 类型大小: {}", size_of_u16);

    // 查看 u8 类型数值范围(仅支持数值类型)
    let min = u8::MIN;
    let max = u8::MAX;
    println!("u8 类型范围:{}-{}", min, max);

    // 编译报错:
    // u8 类型的取值范围是 0 ~ 255,无法存放超过该范围的数值
    // let num: u8 = 256;
}
shell> cargo run
u8 类型大小: 1
u16 类型大小: 2
u8 类型范围:0-255

  在计算机系统中,常见的数据存储单位包括 Bit(位)、Byte(字节)、KB(千字节)、MB(兆字节)、**GB(吉字节)和 TB(太字节) 等。各存储单位之间的换算关系如下:

  • 1 Byte(字节)等于 8 Bit(位)
  • 1024 Byte(字节)等于 1 KB(千字节)
  • 1024 KB(千字节)等于 1 MB(兆字节)
  • 1024 MB(兆字节)等于 1 GB(吉字节)
  • 1024 GB(吉字节)等于 1 TB(太字节)

  数据类型除了决定容器大小(即开关的数量)之外,还决定这些开关的解析方式,不同的数据类型可能采用不同的解析规则。例如,当使用 8 位开关且全部用于表示数据时,可以表示 0~255 共 256 个不同的数值,如果还需要支持负数,则需要采用不同的解析规则,以便在有限的开关状态中同时表示数值大小和正负信息。对于支持负数的整数类型,现代编程语言通常会将最高位作为符号位,用于表示数值的正负:当该位为 1 时表示负数,为 0 时表示正数;其余 7 位用于表示数值大小。因此,8 位整数可以表示的数值范围为 −128~127。

  小结:数据类型决定了变量能够存放的数据种类、容器的大小、数据的取值范围,以及对应的解析规则。

2.4 变量名的本质

  计算机中用于存储数据的核心硬件主要有两类:内存和磁盘(如硬盘、U 盘)。其中,内存主要用于临时存放正在运行的数据(变量)和程序(代码),相比磁盘,其容量通常较小、成本较高,但访问速度更快,不过断电后其中的数据会丢失。磁盘则主要用于长期保存数据,具有容量大、成本低的特点,但访问速度较慢,且断电后数据通常不会丢失。由于磁盘的访问速度远低于内存,磁盘中的数据通常需要先加载到内存,再由 CPU 进行处理。

  内存由一系列存储单元组成,每个存储单元以字节(Byte)为基本单位。程序中的变量值存放在内存的一个或多个连续的存储单元中,所占空间大小由变量的数据类型决定。为了便于访问和管理,每个存储单元都被赋予一个唯一的编号,通常从 0 开始连续递增,这个编号称为内存地址。变量名本质上就是内存地址的别名,它描述了该地址中数据的含义,使程序员能够更直观地在代码中理解和使用这些数据。

  在 Rust 中,可以使用取地址运算符 & 获取变量名对应的内存地址,使用解引用运算符 * 访问该内存地址中存储的数据。

fn main() {
    // 定义一个变量
    let number: u32 = 10;

    // 获取内存地址
    let number_ptr = &number;
    println!("内存地址: {:p}", number_ptr);

    // 获取内存地址中存放的数据
    println!("该地址存放的数据:{}", *number_ptr);
}
shell> cargo run
内存地址: 0x7ffe8ea1d62f
该地址存放的数据:10

2.5 变量的可变性

  在 Rust 中,变量的值默认不可修改;若需修改,必须在定义时使用 mut 关键字显式声明该变量可变。

fn main() {
    let num1 = 100;
    // num = 200;        // 编译失败,变量值默认不允许修改
    println!("变量默认不允许修改:num1={num1}");

    let mut num2= 200;
    println!("修改前:num2={num2}");

    num2 = 300;          // 编译成功,增加 mut 关键字后,变量可以修改
    println!("修改后:num2={num2}");
}
shell> cargo run
变量默认不允许修改:num1=100
修改前:num2=200
修改后:num2=300

2.6 常量

  Rust 语言除了支持变量外,还支持常量。常量使用 const 关键字定义,通常采用全大写字母命名,并且必须显式指定数据类型。对于较小的常量类型,编译器通常会在编译阶段直接将常量值进行内联替换,避免程序运行时的额外存储和访问开销。

fn main() {
    // 定义一个常量,必须明确指定数据类型
    const NUMBER: u8 = 10;

    // 编译时, 会替换为常量的实际值,下面的代码等同于 let sum = 100 + 10;
    let sum = 100 + NUMBER;
    println!("sum is {}", sum);
}

2.7 变量作用域

  每个变量都有自己的作用域,变量只能在其所属作用域内使用和访问,当变量离开作用域时,Rust 会自动释放其占用的资源(例如内存空间)。此外,不同作用域中的同名变量彼此独立,互不影响,内层作用域中的变量可以覆盖外层作用域中的同名变量,但不会改变外层变量的值。

  根据作用域范围的不同,变量通常可以分为局部变量和全局变量。局部变量定义在函数或代码块内部,只能在对应的局部作用域内访问。全局变量定义在函数之外,在程序运行期间始终存在,可以在程序的多个位置访问,常用于存放需要在整个程序中共享的数据。

fn main() {
    let x = 10; // 局部变量 x,作用域从这里开始

    {
        let y = 20; // 局部变量 y,只在这个代码块内有效
        println!("x = {}, y = {}", x, y);
    } // y 在这里离开作用域并被自动释放

    // 编译错误:y 已超出作用域,无法使用和访问
    // println!("{}", y); 

    println!("x = {}", x);
} // x 在这里离开作用域并被自动释放
shell> cargo run
x = 10, y = 20
x = 10

  在 Rust 中,全局变量通过 static 关键字定义,并且必须显式指定数据类型。

// 定义一个全局变量 GLOBAL_COUNT,使用 static 关键字声明
// 该变量具有全局作用域,在程序运行期间始终存在
static GLOBAL_COUNT: i32 = 100;

// 引用全局变量
static GLOBAL_REF: i32 = GLOBAL_COUNT;

fn main() {
    // 在 main 函数中访问全局变量 GLOBAL_COUNT
    println!("GLOBAL_COUNT = {}", GLOBAL_COUNT);

    // 在 main 函数中访问全局变量 GLOBAL_REF
    println!("GLOBAL_REF = {}", GLOBAL_REF);
}
shell> cargo run
GLOBAL_COUNT = 100
GLOBAL_REF = 100

2.8 变量遮蔽

  变量遮蔽(Variable Shadowing)是指在同一作用域内,后声明的变量会覆盖先前声明的同名变量,使原变量暂时无法被访问,二者的数据类型可以不同。变量遮蔽常用于数据类型转换等场景,可以减少程序员在变量命名上的负担,但变量遮蔽也存在一定争议,过度使用可能会降低代码的可读性,建议仅在类型转换时使用。

fn main() {
    // 定义一个 u8 类型的变量 num
    let num: u8 = 10;
    println!("遮蔽前:num={}", num);

    // 在同一作用域中遮蔽 num,数据类型可以不同
    let num: i32 = 200;
    // 此处访问的是后声明的 num(i32 类型)
    println!("遮蔽后:num={}", num);
}
shell> cargo run
遮蔽前:num=10
遮蔽后:num=200

  需要注意的是,变量遮蔽只在当前作用域内生效,当离开变量遮蔽所在的作用域后,原先被遮蔽的变量会重新变得可见。因此,被遮蔽的变量并未被销毁,而只是暂时无法访问。

fn main() {
    // 定义一个 u8 类型的变量 num
    let num: u8 = 10;
    println!("outer num (u8): {}", num);

    {
        // 在内层作用域中遮蔽 num,并将类型改为 i8
        let num: i8 = -10;
        println!("inner num (i8): {}", num);
    } // 内层作用域结束,被遮蔽的 num 失效

    // 外层作用域中的 num 重新可见
    println!("outer num (u8): {}", num);
}
shell> cargo run
outer num (u8): 10
inner num (i8): -10
outer num (u8): 10

2.9 二进制

  在日常生活中,人们最常使用十进制表示数据,即以 10 为基数的计数方式。十进制由 0~9 十个数字符号组成,每一位可以表示十种不同的状态,当某一位达到最大值 9 后继续增加时,该位归零,并向更高位进 1。而在计算机中,由于电子元件通常只能稳定表示两种状态——通电(1)和断电(0),因此计算机采用二进制表示数据。二进制以 2 为基数,每一位只能表示两种状态,即 0 和 1,当某一位达到最大值 1 后继续增加时,该位归零,并向更高位进 1。

  在日常生活中,也可以发现类似二进制的计数方式,例如,周六加周日等于一个周末(即 1 + 1 = 10),进位后单位变为“周末“,这就是一个二进制运算。若再将两个周末相加,满 2 后继续向更高位进位,如此反复,便形成了二进制的计数单位 1、2、4、8、16……,类似于十进制中的 个、十、百、千、万……。

二进制     百万      十万       万        千        百        十         个 
<──────────┼────────┼─────────┼─────────┼─────────┼─────────┼─────────┤
十进制     64       32        16         8         4         2         1

  二进制运算与十进制运算在规则上完全一致,只是进位的基数从 10 变成了 2,二进制加法的基本规则如下:

  • 0 + 0 = 0
  • 0 + 1 = 1
  • 1 + 0 = 1
  • 1 + 1 = 10(向高位进 1)
示例:
  101 (5)          110 (6)          1001 (9)
+ 011 (3)        + 010 (2)        - 0011 (3)
-----            -----            ------
 1000 (8)         1000 (8)          0110 (6)

  Rust 支持使用二进制、十进制、八进制和十六进制来表示数据,八进制满 8 进 1(数位范围为 0~7),十六进制满 16 进 1(数位范围为 0~9 和 A~F),其进位规则与十进制、二进制一致。在 Rust 中,整数默认采用十进制表示,其他进制需要在数值前添加对应的前缀:二进制以 0b 开头,八进制以 0o 开头,十六进制以 0x 开头。需要注意的是,无论代码中使用哪种进制表示数据,计算机在底层存储和处理数据时始终采用二进制形式。不同进制只是对同一个数值的不同表示方式,主要用于提高代码的可读性,以及在不同场景下更直观地表达数据。

fn main() {
    // 同一个数值 16,用不同进制表示
    let decimal = 16;        // 十进制(默认)
    let _binary = 0b10000;    // 二进制
    let _octal = 0o20;        // 八进制
    let _hexadecimal = 0x10;  // 十六进制

    // 将十进制 16 以不同进制格式输出,虽然表示形式不同,但数值本身完全相同
    println!("十进制: {}", decimal);
    println!("二进制: {:b}", decimal);
    println!("八进制: {:o}", decimal);
    println!("十六进制: {:x}", decimal);
}
shell> cargo run
十进制: 16
二进制: 10000
八进制: 20
十六进制: 10

  十六进制则是为了更加直观、便捷地表示和阅读二进制数据而产生的。如果直接阅读二进制数据,需要面对大量连续的 0 和 1,不仅表示形式冗长,也容易出现阅读错误。而十六进制每一位恰好对应 4 个二进制位,可以将较长的二进制数据转换为更简洁、更易读的形式。

┌──────────────────────────────────────────────────────────────────────────────────┐
│                   十六进制(Hex)与二进制(Binary)对应关系表                         │
├──────────┬────────┬────────┬────────┬────────┬────────┬────────┬────────┬────────┤
│   Hex    │   0    │   1    │   2    │   3    │   4    │   5    │   6    │   7    │
├──────────┼────────┼────────┼────────┼────────┼────────┼────────┼────────┼────────┤
│  Binary  │  0000  │  0001  │  0010  │  0011  │  0100  │  0101  │  0110  │  0111  │
├──────────┼────────┼────────┼────────┼────────┼────────┼────────┼────────┼────────┤
│   Hex    │   8    │   9    │   A    │   B    │   C    │   D    │   E    │   F    │
├──────────┼────────┼────────┼────────┼────────┼────────┼────────┼────────┼────────┤
│  Binary  │  1000  │  1001  │  1010  │  1011  │  1100  │  1101  │  1110  │  1111  │
└──────────┴────────┴────────┴────────┴────────┴────────┴────────┴────────┴────────┘
fn main() {
    // Rust 支持使用下划线分割长数值,以便阅读
    let binary = 0b_1101_0101_1010_1100;

    // 十六进制可以将二进制压缩为四位,且更有辨识度
    let hexadecimal = 0xD5AC;

    // 都以十六进制格式输出,输出的值相同
    println!("0x{:X}, 0x{:X}", binary, hexadecimal);
}
shell> cargo run
0xD5AC, 0xD5AC

2.10 位运算符

  Rust 提供了一系列位运算符,直接对数据的每一个二进制位进行操作,包括按位与(AND)、按位或(OR)、按位非(NOT)、按位异或(XOR),以及左移和右移运算符。位运算符常用于性能优化、嵌入式系统、操作系统内核、标志位(flags)管理以及底层协议处理等场景。

运算符描述
&按位与:只有对应的两个二进制位都为 1 时,结果位才为 1。
|按位异或:对应的两个二进制位相同则为 0,不同则为 1。
^按位异或:对应的两个二进制位相同则为 0,不同则为 1。
!按位取反:将每一位的 0 变为 1,1 变为 0。
<<左移:将所有二进制位向左移动若干位,高位被丢弃,低位补 0。
>>右移:将所有二进制位向右移动若干位;无符号数高位补 0,有符号数高位补符号位。

  例如,

fn main() {
    /*
    与运算,按位与,两个值都为 1 时,结果才为 1。
       0001
     & 0010
    ───────
       0000
    */
    let and = 1 & 2;
    println!("1 & 2 = {}", and);

    /*
    或运算,按位或,两个值都为 0 时,结果才为 0。
       0001
     | 0010
    ───────
       0011
    */
    let or = 1 | 2;
    println!("1 | 2 = {}", or);

    /*
    异或运算,按位异或,两个位相同为0,相异为 1。
       0001
     ^ 0010
    ───────
       0011
    */
    let xor = 1 ^ 2;
    println!("1 ^ 2 = {}", xor);

    /*
    取反运算,按位取反,0 变 1,1 变 0。
     ! 0001
    ───────
       1110
    */
    let not = !1;
    println!("!1 = {}", not);

    /*
    左移运算,将各二进位全部左移若干位,高位丢弃,低位补 0
        0000 0001
     <<         1
    ─────────────
        0000 0010
    */
    let left_shift = 1 << 1;
    println!("1 << 2 = {}", left_shift);

    /*
    右移运算,将各二进位全部右移若干位,无符号数高位补 0
        0000 0100
     >>         1
   ──────────────
        0000 0010
    */
    let unsigned_right_shift: u8 = 4 >> 1;
    println!("4 >> 2 = {}", unsigned_right_shift);

    /*
    右移运算,将各二进位全部右移若干位,有符号数高位补 1
        1000 0000
     >>         1
   ──────────────
        1100 0000
    */
    let signed_right_shift: i8 = -128 >> 1;
    println!("126 >> 2 = {}", signed_right_shift);
}

参考链接

https://www.ruanyifeng.com/blog/2007/10/ascii_unicode_and_utf-8.html

https://rustmagazine.github.io/rust_magazine_2021/chapter_12/simple-rust-in-assembly.html