主题
字号
CHAPTER 03 ≈ 40 MIN READ

Rust 长什么样——给 C 党的语法速览

这一章是可选的硬核内容。如果你只想了解Rust的存在价值和适用场景,可以跳过这章,直接看第四章,不影响理解。

如果你想看看Rust代码实际长什么样,这章给你一个概览——不求你现在就能写Rust,但求看懂基本结构,对语法有直觉。

3.1 变量、函数、控制流:和C像不像

3.1.1 变量声明:let和mut

C里声明变量:

int x = 5;
x = 10; // 可以改

Rust里:

let x = 5;       // 不可变绑定,类型自动推断为i32
x = 10;       // 编译错误!默认不可变!
let mut y = 5;   // 可变绑定
y = 10;          // OK

最大的区别:Rust默认变量是不可变的(immutable)。想要可变必须显式加mut。这个设计初看有点烦,但它有几个好处:

  1. 不可变性让代码更容易推理——你看到一个没有mut的变量,就知道它不会变,不用追踪整个代码路径。
  2. 编译器可以做更激进的优化。
  3. 在多线程场景,不可变数据天然是安全的,不需要锁。

这和C的设计哲学相反——C里const是显式加的,可变是默认。这个选择是有意义的:在1970年代,不可变性不是重要关注点;在现代软件工程里,不可变性被认为是好的实践。

**遮蔽(Shadowing)**是Rust特有的一个小特性:

let x = 5;
let x = x + 1;    // 重新声明x,遮蔽了上面的x
let x = x * 2;    // 又遮蔽
println!("{}", x); // 输出12

遮蔽允许你复用变量名,甚至改变类型,同时不需要mut——每次let都创建了一个新的绑定。

3.1.2 基本类型

Rust的基本类型和C类似,但更明确:

Rust类型 C类型 说明
i8, i16, i32, i64, i128 int8_t 有符号整数,位数明确
u8, u16, u32, u64, u128 uint8_t 无符号整数
isize, usize ptrdiff_t, size_t 指针大小的整数
f32, f64 float, double 浮点数
bool _Bool / bool 布尔值,只有true/false
char char 32位Unicode字符,不是C的1字节char
&str const char* 字符串切片(不可变,UTF-8)
String string 堆分配的可增长字符串

注意Rust的char是32位的——每个char是一个Unicode码点,不是字节。这和C的char本质不同,避免了很多字符编码的坑。

3.1.3 函数

Rust的函数:

fn add(x: i32, y: i32) -> i32 {
    x + y  // 注意:没有return,最后一个表达式的值就是返回值
}

fn greet(name: &str) -> String {
    format!("你好,{}", name)  // format!是宏,类似sprintf
}

fn nothing() {
    // 没有显式返回类型表示返回(),即unit类型(类似C的void)
}

两个值得注意的地方:

函数参数必须显式标注类型,没有自动推断。这是有意为之——函数签名是接口契约,必须明确。

最后一个表达式可以省略return和分号,它的值就是函数的返回值。在Rust里,几乎一切都是表达式,包括ifmatch、代码块。这一点来自函数式编程语言的影响。

fn abs(x: i32) -> i32 {
    if x >= 0 { x } else { -x }
    // if是表达式,整个if的值是x或-x
}

3.1.4 控制流:熟悉但有差异

if/else:和C一样,但条件不需要括号,而且是表达式。

let number = 7;
if number < 5 {
    println!("小");
} else if number == 5 {
    println!("等于5");
} else {
    println!("大");
}

// if作为表达式
let description = if number > 5 { "大" } else { "小" };

loop:无限循环,用break退出,可以从break返回值。

let result = loop {
    // 做一些事情
    let x = some_computation();
    if x > 100 {
        break x; // 从loop返回x的值
    }
};

while:和C基本一样。

let mut n = 0;
while n < 10 {
    n += 1;
}

for:这里Rust和C差异比较大。Rust的for是迭代器循环,不是C风格的计数器循环:

// Rust的for
for i in 0..10 {  // 0..10是Range,一个迭代器
    println!("{}", i);
}

// 遍历数组
let arr = [10, 20, 30, 40, 50];
for element in arr.iter() {
    println!("{}", element);
}

不存在C风格的for (int i = 0; i < n; i++)。如果你需要下标,用enumerate()

for (i, val) in arr.iter().enumerate() {
    println!("arr[{}] = {}", i, val);
}

3.2 所有权三板斧:Move / Borrow / Lifetime

3.2.1 Move:转移所有权

我们在第二章已经介绍了Move的概念,这里用代码更具体地展示:

fn take_ownership(s: String) {
    println!("{}", s);
    // s在这里离开作用域,String被释放
}

fn main() {
    let s = String::from("hello");
    take_ownership(s);  // s的所有权移入函数
    println!("{}", s);  // 编译错误!s已经被移走了
}

和C对比:C里传指针给函数,函数可以自由地操作指针,包括free它,调用者完全不知道。Rust的Move让"所有权转移"在语言层面是可见的、可追踪的。

Clone:如果你确实需要两份数据,显式clone:

let s1 = String::from("hello");
let s2 = s1.clone(); // 深拷贝,两份独立的数据
println!("{} {}", s1, s2); // 都可用

Clone是显式的,这很重要——你看到.clone()就知道这里有一次堆内存分配和数据复制,性能敏感的场景要注意。和Python不同,Python里a = b对于列表来说只是浅拷贝,可能有意外的副作用;Rust的设计强迫你想清楚自己要什么。

3.2.2 Borrow:引用和借用

借用是通过引用实现的,&是不可变引用,&mut是可变引用:

fn calculate_length(s: &String) -> usize {
    s.len()  // s是借来的,不会在这里被释放
}

fn change(s: &mut String) {
    s.push_str(", world"); // 可以修改,因为是&mut
}

fn main() {
    let s = String::from("hello");
    let len = calculate_length(&s); // 传引用,不移动
    println!("{} 的长度是 {}", s, len); // s还在

    let mut s2 = String::from("hello");
    change(&mut s2); // 传可变引用
    println!("{}", s2); // "hello, world"
}

关键规则回顾:

3.2.3 Lifetime:生命周期注解

生命周期是Rust里最让新手头疼的概念。大多数时候编译器可以自动推断生命周期,不需要你显式写。但在某些情况下,编译器需要你帮它一把。

最典型的场景是:函数的返回值是一个引用,编译器需要知道这个引用的有效期是由哪个参数决定的。

// 这个函数返回两个字符串切片中较长的那个
// 'a是生命周期参数,读作"lifetime a"
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() { x } else { y }
}

这里的'a说的是:返回值的生命周期和参数xy的生命周期中较短的那个一样长。这让编译器能在调用方检查:你拿到这个引用,能不能保证在使用它的时候,原始数据还活着?

fn main() {
    let string1 = String::from("长字符串很长");
    let result;
    {
        let string2 = String::from("xyz");
        result = longest(string1.as_str(), string2.as_str());
        println!("{}", result); // OK,string2在这里还活着
    }
    // println!("{}", result); // 编译错误!string2已经离开作用域,result的引用不再有效
}

生命周期注解不会改变程序的运行时行为,它只是给编译器提供信息,让编译器能做静态分析。在Rust 2018之后,很多常见场景有了"生命周期省略规则"(lifetime elision),不需要显式写生命周期。

老实说:生命周期是Rust学习曲线上最陡的那一段。如果你现在看不明白,完全正常,需要真正写Rust才会逐渐理解。我的建议是,把这一节当作"知道有这个东西,大概了解它是为了解决什么问题就够了"。

3.3 枚举和模式匹配:比C的union好在哪

3.3.1 C的union:手动类型标签

C语言里有union,允许同一块内存存储不同类型的数据:

union Value {
    int integer;
    double floating;
    const char* string;
};

但C的union有个根本问题:它不记录当前存的是哪种类型。你必须自己用一个额外的字段(通常叫"tag")来记录:

typedef struct {
    enum { INT_TYPE, FLOAT_TYPE, STRING_TYPE } tag;
    union {
        int integer;
        double floating;
        const char* string;
    } value;
} TaggedValue;

然后使用时:

TaggedValue v;
v.tag = INT_TYPE;
v.value.integer = 42;

// 必须手动检查tag
if (v.tag == INT_TYPE) {
    printf("%d\n", v.value.integer);
} else if (v.tag == FLOAT_TYPE) {
    printf("%f\n", v.value.floating);
}

// 没有任何机制防止你读错类型:
// printf("%f\n", v.value.floating); // 如果tag是INT_TYPE,这是未定义行为

这很繁琐,而且容易出错——没有人强制你在读之前检查tag。

3.3.2 Rust枚举:类型安全的带数据枚举

Rust的enum是**代数数据类型(Algebraic Data Type, ADT)**的一种,每个变体可以携带不同类型的数据,而且类型是安全的——你必须通过模式匹配来访问,不可能读错类型:

enum Value {
    Integer(i64),
    Float(f64),
    Text(String),
    Nothing,  // 不携带数据的变体
}

let v = Value::Integer(42);

// 必须通过match来访问,不能直接读
match v {
    Value::Integer(n) => println!("整数:{}", n),
    Value::Float(f) => println!("浮点:{}", f),
    Value::Text(s) => println!("字符串:{}", s),
    Value::Nothing => println!("空"),
}

编译器强制你处理所有的变体(除非你用_通配符省略)。如果你加了一个新的变体但忘记更新某处的match,编译器会报错。这对大型代码库的维护非常友好。

3.3.3 Option:消灭空指针

Rust里没有null。这不是说法语——Rust里真的没有null这个概念(在安全代码里)。取而代之的是Option<T>类型:

enum Option<T> {
    Some(T),  // 有值
    None,     // 没值
}

一个可能不存在的值,类型就是Option<T>,而不是裸的T。你必须通过模式匹配或者其他方式显式处理None的情况:

fn divide(a: f64, b: f64) -> Option<f64> {
    if b == 0.0 {
        None
    } else {
        Some(a / b)
    }
}

let result = divide(10.0, 2.0);
match result {
    Some(value) => println!("结果是 {}", value),
    None => println!("不能除以零"),
}

// 也可以用if let,更简洁
if let Some(value) = divide(10.0, 2.0) {
    println!("结果是 {}", value);
}

// 或者用unwrap(如果你确信一定有值,否则会panic)
let value = divide(10.0, 2.0).unwrap();

// 或者提供默认值
let value = divide(10.0, 0.0).unwrap_or(0.0);

Option消灭了"空指针解引用"这类错误——你不可能忘记处理None,因为编译器不让你直接把Option<T>T用。

Tony Hoare(null的发明者)在2009年的一次演讲里称null为"我的十亿美元错误"。Rust选择了彻底废除null。

3.3.4 模式匹配:match不只是switch

match在Rust里远比C的switch强大。它可以匹配几乎任何东西:

let pair = (0, -2);

match pair {
    (0, y) => println!("第一个是零,第二个是{}", y),
    (x, 0) => println!("第一个是{},第二个是零", x),
    _ => println!("其他情况"),
}

// 匹配范围
let x = 5;
match x {
    1..=5 => println!("1到5之间"),
    6..=10 => println!("6到10之间"),
    _ => println!("其他"),
}

// 匹配结构体字段
struct Point { x: i32, y: i32 }
let p = Point { x: 0, y: 7 };
match p {
    Point { x: 0, y } => println!("在y轴上,y={}", y),
    Point { x, y: 0 } => println!("在x轴上,x={}", x),
    Point { x, y } => println!("({}, {})", x, y),
}

模式匹配是声明式的——你说"当数据长这样的时候做这个",而不是命令式地"先检查这个,再检查那个"。这让复杂的条件逻辑更清晰、更不容易遗漏情况。

3.4 错误处理:Result代替异常

3.4.1 三种错误处理思路

在编程语言里,处理错误有几种主流思路:

1. 返回错误码(C的方式)

int result = some_function(args);
if (result < 0) {
    // 错误
    perror("failed");
    return -1;
}

问题:容易忘记检查,错误码的含义不统一,错误信息不够丰富。

2. 异常(Python、Java、C++的方式)

try:
    result = might_fail()
except ValueError as e:
    handle_error(e)

优点:不能"忘记"——异常会自动向上传播,如果没有被处理最终会让程序崩溃。 问题:函数签名里看不出它会抛什么异常(Python没有checked exception),运行时有开销,控制流不直观(异常可以从任何地方"穿越"到上层)。

3. Result类型(Rust、Haskell的方式)

Rust的错误处理是通过Result<T, E>类型实现的:

enum Result<T, E> {
    Ok(T),   // 成功,携带T类型的返回值
    Err(E),  // 失败,携带E类型的错误信息
}

3.4.2 Result的使用

use std::fs::File;
use std::io;

fn open_file(path: &str) -> Result<File, io::Error> {
    File::open(path) // 返回Result<File, io::Error>
}

fn main() {
    let result = open_file("hello.txt");
    match result {
        Ok(file) => println!("文件打开成功"),
        Err(e) => println!("错误:{}", e),
    }
}

你不能直接把Result<File, io::Error>File用,必须先处理错误情况。这和Option消灭null的思路一样——把可能的错误编进类型系统,强制你面对它。

3.4.3 ?运算符:优雅的错误传播

但是如果每一个可能失败的操作都要写match,代码会非常啰嗦。Rust提供了?运算符来简化错误传播:

use std::fs::File;
use std::io::{self, Read};

fn read_username_from_file() -> Result<String, io::Error> {
    let mut file = File::open("username.txt")?;
    // ?的意思是:如果是Err,立即从当前函数返回这个Err;如果是Ok,展开Ok里的值

    let mut username = String::new();
    file.read_to_string(&mut username)?;

    Ok(username)
}

?相当于:

// 展开版本
let mut file = match File::open("username.txt") {
    Ok(f) => f,
    Err(e) => return Err(e),
};

这在行为上类似于其他语言的异常传播——错误会自动向上传递,但它是显式的(你看到?就知道这里有错误传播),而且函数签名里清楚地写明了它返回Result,调用者知道要处理错误。

3.4.4 panic!:不可恢复的错误

Rust还有panic!宏,用于处理程序逻辑错误——那种理论上不应该发生、但发生了说明程序有bug的情况:

let v = vec![1, 2, 3];
v[10]; // 越界访问,在debug模式下panic,打印错误信息和堆栈

panic!会展开调用栈(或直接abort),打印错误信息。它不是用来处理"正常的错误情况"(文件不存在、网络超时)的,那些用Result处理。panic!是"这不应该发生,程序遇到了无法恢复的状态"的信号。

类比:

3.5 Cargo:Rust的包管理工具

3.5.1 C的痛苦:没有标准包管理

从C党的视角来看,Cargo可能是Rust最让人心动的特性之一。

C没有官方的包管理工具。你想用一个第三方库,要么系统自带(apt install libssl-dev),要么手动下载源码、编译、链接,要么用vcpkg、Conan这些第三方包管理器(但它们不是标准的)。CMakeLists.txt的依赖管理可以把人逼疯。

C++稍好一点,但标准化的包管理在C++领域直到2020年代还是一个让人头疼的问题。

3.5.2 Cargo:一站式解决

Rust从一开始就内置了Cargo,它同时是:

一个新Rust项目:

cargo new my_project  # 创建项目
cd my_project

Cargo.toml(项目配置文件,类似package.json):

[package]
name = "my_project"
version = "0.1.0"
edition = "2021"

[dependencies]
serde = { version = "1.0", features = ["derive"] }
reqwest = { version = "0.11", features = ["json"] }
tokio = { version = "1", features = ["full"] }
cargo build    # 构建
cargo run      # 构建并运行
cargo test     # 运行测试
cargo add serde  # 添加依赖(会自动修改Cargo.toml)

Cargo会自动下载依赖、管理版本冲突、缓存编译结果。和C相比,这是另一个世界。

Crates.io(发音"crates dot io")是Rust的官方包仓库,截至2025年已有超过15万个crate(Rust的包称为crate)。质量参差不齐,但核心生态的几个大库——tokio(异步运行时)、serde(序列化)、reqwest(HTTP客户端)——都是高质量、广泛维护的。

3.5.3 trait:Rust的接口系统

简单提一下trait,因为它是Rust生态里无处不在的概念。

trait类似Java的接口或Python的抽象基类,定义一组类型必须实现的方法:

trait Summary {
    fn summarize(&self) -> String;
}

struct Article {
    title: String,
    content: String,
}

impl Summary for Article {
    fn summarize(&self) -> String {
        format!("{}: {}...", self.title, &self.content[..50])
    }
}

Rust的trait系统比Java接口更强大的地方在于:你可以给任何类型实现任何trait,包括你没有写的类型(在一定限制下)。这是一种叫做"特设多态"(ad hoc polymorphism)的能力,让代码组合非常灵活。

Rust没有传统意义上的继承(类和子类)。它用trait来实现代码复用和多态,这是一种不同的面向对象思路,更接近组合而不是继承。如果你熟悉Go的接口系统,Rust的trait和它有些像,但功能更强大。

好了,第三章到这里。所有权三板斧、枚举、模式匹配、错误处理——这四个东西是Rust语法最有特色的部分,也是和C/Python差异最大的地方。

说实话,只看文字描述很难真正理解这些概念。所有权和借用需要你亲手写代码,被借用检查器骂几次,才会慢慢内化。这章的目的只是给你一个印象:Rust的语法在哪些地方和C像(控制流、基本类型、函数),在哪些地方根本不同(所有权、枚举、错误处理)。

下一章我们回到宏观视角:Rust实际上跑在哪里,解决了谁的问题,什么场景用它是多此一举。