可变性 ------ 数据的一个属性
一、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 提供了
Cell、RefCell(单线程)和Mutex、Atomic(多线程)等受控的可变工具------这些在独立章节详述。