从C到Rust:mut 可变性,与恶魔战斗的利剑

可变性 ------ 数据的一个属性

一、C 中的可变性:一切都是可变的

1.1 C 的默认

在 C 中,变量默认是可变的

c 复制代码
int x = 42; // 默认可变

x = 43; // ✅ 可以修改

  


const int y = 42; // 加了 const

// y = 43; // ❌ 编译错误

但 C 的 const 只是"软约束"------它可以通过指针绕过:

c 复制代码
const int y = 42;

  


// const 可以被强制转换去掉!

int* p = (int*)&y;

*p = 43; // ❌ 未定义行为(修改了 const 变量)

// 编译器允许!运行时可能崩溃,也可能不

C 的 const 是"推荐"不是"强制"。

1.2 C 的 const 是关于"指针"的,不是关于"数据"的

c 复制代码
const int* p; // "指向 const int 的指针" ------ 通过 p 不能改

int* const q; // "const 指针" ------ q 不能指向别处

int* r; // 无限制

  


// 真正的漏洞:

const int* p = &some_int;

int* q = (int*)p; // const_cast --- 去掉了 const

*q = 42; // 现在 some_int 被改了

在 C 中,const 描述的是"这个指针的访问权限",而不是"这块数据的本性"。多个指针可以指向同一数据,其中一些是 const,另一些不是------编译器无法阻止非 const 指针的修改。

1.3 C 没有"默认不可变"的概念

C 程序员需要手动为每个变量加上 const 来防止意外修改------但 const 不是默认行为,而且容易被绕过:

c 复制代码
// 函数签名可能误导

void process(const int* data); // 说不会修改数据

  


// 但谁能保证实现真的是这样?

// 它内部可能 cast 掉了 const!

在 Rust 之前,没有语言在编译器层面保证"不可变数据真正不会被修改"。


二、意外写入:最难以排查的 Bug

2.1 为什么意外写入比崩溃更可怕

程序崩溃(crash)是一种"好"的 bug------它大声宣告自己出了问题。崩溃可以复现、可以加日志、可以用调试器定位。

意外写入是一种"坏"的 bug------它静静地腐蚀你的数据,然后在完全不相干的地方引爆。

css 复制代码
错误写入发生的时间点: 变量 a 被错误地写成了 43

┃

┃ (数据已经损坏,但程序继续运行)

┃

┃ (a 的值在几十个函数间传递)

┃

┃

错误被发现的时间点: a 被当作数组索引使用,访问违例

⇓

程序崩溃 ------ 但崩溃的地方离写入的地方

已经隔了十万八千里

这个时间差和空间差,就是意外写入的可怕之处。

2.2 C 中的意外写入:最常见的例子

例 1:全局变量的意外修改

c 复制代码
int g_counter = 0; // 全局变量

  


void func_a() {

g_counter++; // 应该如此

}

  


void func_b() {

// 某人在某个版本中加了一行:

g_counter = -1; // ❌ 意外写入!本意是写局部变量

}

  


void func_c() {

// 依赖 g_counter 做数组索引

process_array[g_counter]; // ❌ g_counter = -1 → 越界

}

func_b 的错误发生在一个版本中,func_c 的崩溃在另一个版本中被发现。中间可能隔了三个月、十次代码提交、五个人。谁写入了 -1?git blame 可以告诉你,但你是花了一周时间才找到这一步的。

例 2:指针别名导致的意外写入

c 复制代码
void process(struct User* user, int* id_out) {

// ... 复杂逻辑 ...

*id_out = user->id;

  


// 更多逻辑,其中某一行:

user->age = calculate(user, id_out);

// ↑ 如果 id_out 指向 user->id 呢?

// 那 *id_out = user->id 就覆盖了 user->id 的值!

// 然后 user->id 变成了一个被修改过的值

}

  


void caller() {

struct User u = { .id = 100, .age = 30 };

process(&u, &u.id); // ✅ C 中合法,但 id_out 就是 &u.id

// process 内部的写入意外地修改了 u.id

// 调用者完全不知道

}

例 3:回调中的意外写入

c 复制代码
int state = 0;

  


void callback(void* ctx) {

state = 42; // ❌ 假设不会修改全局状态

}

  


void iterate(void (*cb)(void*)) {

int state = 0; // 局部变量,和全局重名

cb(NULL); // 回调修改了全局的 state!

// 程序员以为 state 是局部的......

}

例 4:const 被意外去掉

c 复制代码
const int max_size = 100;

  


void unsafe_func(int* p) {

*p = 200; // 假设这里知道 p 不指向 const

}

  


void safe_func() {

unsafe_func((int*)&max_size); // ❌ const cast!

// max_size 被错误地修改了!

// 所有依赖 max_size 做边界检查的代码都失效了

}

2.3 为什么这种 bug 极难排查

原因 1:写入和崩溃在空间上分离

scss 复制代码
写入在此 → 崩溃在彼

func_b():30行 func_c():120行

g_counter = -1 arr[g_counter]

调试器停在崩溃处(120行),但你需要倒推出 30 行的写入。如果函数调用链有 10 层深,这意味着你要读几百行代码。

原因 2:写入和崩溃在时间上分离

scss 复制代码
提交 A(1月):加入 func_b(),g_counter 被意外写入

提交 B(3月):加入 func_c(),使用 g_counter 做索引

提测 (3月):偶现越界,复现率 30%

定位 (4月):找到是 g_counter 的值不对

溯源 (4月):用 git bisect 找到提交 A

修复 (4月):改 func_b() 的一行代码

从写入到发现,三个月。这还算快的。

原因 3:偶发性 ------ 依赖执行路径

c 复制代码
void process(int flag) {

if (flag) {

g_counter = -1; // 只有 flag=1 时触发

}

// ... 大量代码 ...

arr[g_counter] = 42; // 只有 flag=1 时崩溃

}

如果测试环境的 flag 总是 0,这个问题永远不会被发现。等到生产环境 flag=1 时------你已经下线了。

原因 4:线程间的意外写入(数据竞争)

c 复制代码
// 线程 1:

int x = shared_counter;

x = x + 1;

shared_counter = x; // 可能被线程 2 的写入覆盖

  


// 线程 2:

shared_counter = 0; // ❌ 在线程 1 写入后重置了 counter

两个线程的执行顺序每次都可能不同,所以 bug 有时候出现、有时候不出现。在 Debug 模式下可能没事,Release 模式下崩溃------因为编译器的指令重排暴露了竞态条件。

原因 5:工具检测有盲区

  • 编译器:C 的编译器对意外写入几乎不报错(除非启用 -Wunused 等检查,且不是所有情况都能覆盖)

  • 静态分析:Coverity、Clang Static Analyzer 可以发现一部分,但有误报和漏报

  • 运行时工具:Valgrind、ASan 能检测 use-after-free 和越界,但检测不了"逻辑上不该写但写了"的情况

  • Code Review:人眼会漏------尤其是在大型函数和复杂调用链中

2.4 真实世界的代价

erlang 复制代码
最著名的案例之一:Heartbleed 漏洞(CVE-2014-0160)

  


本质:一个边界检查中的错误写入。

OpenSSL 的 heartbeat 扩展没有正确验证数据包长度,

导致攻击者可以读取服务器内存中本不应被读取的数据。

  


影响:互联网上 ~17% 的 HTTPS 服务器

修复:一行代码

损失:数亿美元的安全审计和证书更换

并不是每个意外写入都会导致 Heartbleed 级别的灾难。但每个意外写入都在增加软件的熵------让你的代码库变得更脆弱、更难维护。

2.5 这些问题在 C 中无法根本解决

问题的根源是:C 没有在语言层面提供"数据不可变"的保证。

  • 你可以在变量前加 const,但可以通过 cast 绕过

  • 你可以传 const 指针,但不能保证接收方不强制转换

  • 你可以建立代码规范"不要修改全局变量",但不能保证编译器帮你检查

所有防护都是"软"的------靠人的记忆、靠代码规范、靠审查纪律。 而人的记忆是不可靠的,代码规范会被人忘记,审查纪律在 deadline 面前会被打破。

2.6 从意外写入看 Rust 的设计

把上面的例子汇总一下,看清它们的共同根源:

c 复制代码
例 1:全局变量 g_counter 被 func_b 意外写入

→ C 不区分"这个变量只在这里用"和"这个变量在其他地方也会被改"

→ 所有变量默认可变,任何函数都能写任何全局变量

  


例 2:指针别名------id_out 和 user->id 是同一个地址

→ C 不区分"只读指针"和"独占写指针"

→ int* 同时承担"读"和"写"两种语义

  


例 3:回调修改了全局 state

→ C 的函数指针不携带"这个函数是否会修改外部状态"的信息

→ int (*cb)(void*) 看不出 cb 会不会写全局变量

  


例 4:const 被 cast 掉

→ C 的 const 是"推荐"不是"强制"

→ (int*)&const_var 可以绕过所有保护

每个例子的根因都是同一个:C 默认所有数据都是可变的,编译器不阻止任何写入。

Rust 的答案是相反的:默认不可变,想写必须显式声明。

arduino 复制代码
C: 所有变量默认可写 → 意外写入无处不在地发生

Rust:所有变量默认不可写,let mut 声明后可写 → 写入是有意为之的、显式的

  


C: int* 同时是读和写 → 读写混淆,别名写入

Rust:&T 只读 / &mut T 独占写 → 读写分开,无法混淆

  


C: 函数指针不携带"是否修改"信息 → 回调可能意外修改状态

Rust:Fn 不可变捕获 / FnMut 可变捕获 → 捕获方式明确告诉调用者

  


C: const 可被 cast 绕过 → const 只是提醒

Rust:let 不可变------没有 const_cast → 不可变是真正的保证

Rust 的 let vs let mut 不是设计上的"偏好",而是对 C 几十年来"意外写入→难以调试→安全漏洞"这一链条的正面回应。 每一个 C 中因"一切皆可写"导致的问题,在 Rust 中都有一个对应的编译期检查。


三、Rust 中可变性是数据的属性

3.1 let vs let mut:可变性在声明时决定

Rust 在变量声明时就决定了数据的可变性:

rust 复制代码
let x = 42; // 不可变 ------ 编译器保证值永远不会改变

// x = 43; // ❌ 编译错误:不能对不可变变量赋值

  


let mut y = 42; // 可变 ------ 可以修改

y = 43; // ✅

不可变是默认的,可变需要显式声明。 这与 C 完全相反。

3.2 不可变性的真正含义

在 Rust 中,let x = 42 意味着:

markdown 复制代码
1. 不能直接赋值:x = 43 → 编译错误

2. 不能取 &mut 引用:&mut x → 编译错误

3. 所有通过 &T 的访问都是只读的 → 编译器保证

4. 没有任何方式可以绕过这些规则 → 没有 "const_cast"

Rust 的不可变是真正的不可变。 不存在"通过指针绕过"的可能性。

rust 复制代码
let x = 42;

  


// 所有合法的操作都是只读的

let r1 = &x; // ✅ 可以取 &T

let r2 = &x; // ✅ 多个 &T 可以共存

println!("{}", x); // ✅ 可以读

  


// 所有修改操作都是非法的

// let r3 = &mut x; // ❌ 编译错误:不能对不可变变量取 &mut

// x = 43; // ❌ 编译错误:不能对不可变变量赋值

  


// 没有 const_cast,没有 (int*)&x------这是安全的

3.3 mut 是视觉标记

看到 let mut,你就知道"这个值会被修改"。看到 let,你就知道"这个值从创建到销毁不会变"。

rust 复制代码
fn example() {

let x = compute_value(); // 不变 ------ 只读一次

let mut y = vec![]; // 可变 ------ 后面会 push、pop、clear

let z = &x; // 不变 ------ 只读

// ...

}

在代码审查中,let mut 是高亮标记------它会吸引审查者的注意力:"为什么这里需要可变?"

C 中没有这种信号。在 C 中,int x = compute(); 之后,x 随时可能被修改,你看不出来。

3.4 外部的可变性:&T vs &mut T

如果一个值是可变的(let mut x),它可以通过两种方式被访问:

rust 复制代码
let mut x = 42;

  


// 方式 1:通过 &T ------ 只读访问

let r: &i32 = &x; // 其他代码只能读 x

// *r = 43; // ❌ 不能通过 &T 写入

  


// 方式 2:通过 &mut T ------ 读写访问

let r: &mut i32 = &mut x; // 独占写入权

*r = 43; // ✅

外部可变性的规则是:&T&mut T 不能同时活跃。 这是借用规则------一个值要么有任意多个 &T,要么有一个 &mut T

3.5 外部的可变性的局限

外部可变性(&T vs &mut T)由编译器在编译期检查。大部分场景下这是够用的------你清楚地知道哪些代码是只读的、哪些代码是独占写的。

但有一些场景,外部可变性无法满足需求:

rust 复制代码
use std::rc::Rc;

  


// 场景:引用计数------递增计数需要在 &self 下修改

let r = Rc::new(42);

let r2 = Rc::clone(&r);

// ↑ Rc::clone 通过 &Rc<i32> 调用

// 内部需要递增引用计数------通过 &self 修改内部状态

  


// 场景:缓存------计算后缓存结果

fn get_cached(&self, key: &str) -> &Value {

// 想要更新内部缓存(但不改变 &self)

// self.cache.insert(key, computed_value);

// ❌ self 是 &self------不能 insert

}

这些场景的共同点:需要在 &self(共享引用)下修改数据。 外部可变性只允许在 &mut self 下修改------但有些架构(如引用计数、缓存)的修改必须通过共享引用暴露。

Rust 的解决方案:内部可变性

Rust 提供了一组类型,把借用检查从编译期推迟到运行时 ------让你可以在 &T 下修改数据。

r 复制代码
外部可变性(&mut T):

编译期检查 → 零运行时开销

限制:不能通过 &T 修改

  


内部可变性(Cell / RefCell):

运行时检查 → 少量运行时开销

优势:可以在 &T 下修改

  


跨线程内部可变性(Mutex / Atomic):

锁 / 原子指令 → 同步开销

优势:可以在多线程的 &T 下修改
方式 检查时机 限制 适用场景
&mut T 编译期 独占访问 大多数场景(首选)
Cell<T> 无(Copy 类型直接操作) Copy 类型 计数器、标志位
RefCell<T> 运行时(违反规则 panic) 单线程 缓存、Rc+RefCell
Mutex<T> 运行时(锁阻塞) 多线程 跨线程共享数据
AtomicU32 CPU 原子指令 简单整数/布尔 跨线程计数器

外部可变性 vs 内部可变性的关系:

r 复制代码
外部可变性 = 编译期检查 → 零开销、但限制严格(不能通过 &T 修改)

内部可变性 = 运行时检查 → 少量开销、但灵活(可以通过 &T 修改)

  


选择原则:能用外部就不用内部,能用编译期检查就不用运行时检查。

四、小结

4.1 C vs Rust 的核心差异

维度 C Rust
默认可变性 可变 不可变
const / 不可变 可以被 (int*) cast 绕过 编译器保证,无法绕过
可变性是... 变量的隐含属性 数据的显式声明(let mut
代码审查信号 无(不知道哪个变量会被改) let mut 是显式标记
意外写入防护 靠人的记忆 编译器禁止

4.2 Rust 如何解决了"意外写入"

C 的场景 Rust 中如何阻止
全局变量被意外修改 全局变量默认不可变,static mut 必须 unsafe
指针别名意外写入 &T 保证不可写,&mut T 保证独占
回调修改外部状态 闭包捕获明确声明 move / &mut
const 被 cast 掉 没有 const_cast,&T&mut T 不可能
线程间数据竞争 &T 是 Send 安全的;&mut T 独占

一句话总结 :C 中"一切皆可写"的默认设计是意外写入的根源。Rust 通过"默认不可变 + 显式 mut"让可变性成为代码中的一个显式声明,所有修改都是有迹可循的。对于需要在共享引用下修改的场景,Rust 提供了 CellRefCell(单线程)和 MutexAtomic(多线程)等受控的可变工具------这些在独立章节详述。

相关推荐
Rockbean1 小时前
10分钟-AI × Solana:2.MCP服务器——AI代理接入Solana的标准化通道
rust·web3·智能合约
一次旅行16 小时前
【AI工具】Rust-Based CLI:用 xargs 和并行加速你的 Linux 日常
linux·开发语言·rust
梦醒沉醉20 小时前
6、Rust程序设计语言——包、crates与模块
rust
花褪残红青杏小21 小时前
Rust图像处理第16节-曼德博集合 Mandelbrot:用一行公式画出无限细节
rust·webassembly·图形学
Arman376821 小时前
rusty-cat × 阿里云 OSS 直传直下实战
rust·ai编程
临风细雨1 天前
从 Bun 的 Rust 重写,看 C# 如何重建 AI 基础设施层
人工智能·rust·c#
x-cmd1 天前
Linus 9 年前说「模糊测试有效」——今天已成 OSS-Fuzz 时代铁律
linux·ai·rust·开源·自动化·agent·安全性测试
CodeForWater1 天前
RUST设置临时环境变量在PowerShell
rust
独孤留白1 天前
从C到Rust:Trait Deref + DerefMut 智能指针与自动解引用
rust