跳转到内容

3.1 编译期类型实体:信息搬到了哪里

看到第二篇的标题是「类型」,如果你有 Java、C# 或 Python 等面向对象(OOP)语言的背景,心里很可能会冒出一个疑问:「类型(Type)不就是 int、float、boolean 那些基础数据类型吗?一张表格十分钟就能看完,为什么要用整整一篇的篇幅去讲类型?」

这份疑惑,恰恰来自其他语言与 Rust 在底层世界观上的第一个分水岭。

从「一切皆对象」到「一切皆类型」

Section titled “从「一切皆对象」到「一切皆类型」”

在许多主流语言里,开发者脑子里的概念是割裂的,或者全都托付给了运行时的「对象」:

  • 在 Java 中,提到数据类型(Primitive Type),通常只指 int、double、boolean 这几个简单的数值;一旦涉及复杂的数据结构和业务逻辑,主角就变成了类(class)与对象(Object)——在 OOP 的视角里,一个 class 就是「一块存放字段的内存 + 一组绑在上面的方法(Method)」。
  • 在 Python 中,更是把「一切皆对象」推到了极致:连一个最简单的整数 1,在运行时都是堆上顶着二十多字节头部的 PyObject 对象。

但在 Rust 里,根本没有 class 这个关键字,也没有「基本类型 vs 对象」的二元割裂。

如果说面向对象语言在运行时追求「一切皆对象」,那么 Rust 在编译期贯彻的就是「一切皆类型(Everything is a Type)」:

  • 一个 4 字节的整数 i32 是类型;
  • 带着堆缓冲区的字符串 String、你自己定义的结构体 User 是类型;
  • 连在其他语言里只算「访问方式或语法特性」的只读引用 &String、可变引用 &mut i32、数组切片 &[u8],甚至后续章节会见到的每一个函数和闭包,全都是各自独立、地位平等的类型。

更重要的是,在 Rust 里,一个类型身上承载的东西,既不像 C 语言那样只有裸内存布局,也不只是 OOP 里的「内存字段 + 方法」。

很多学过 C 语言或底层开发的读者,会带入另一种直觉:「类型无非就是告诉编译器这段内存占几个字节、按什么格式解读。」

真是这样吗?我们直接写一段代码,用标准库的 std::mem::size_of 打印几个类型在栈上占用的字节数(64 位平台):

源文件:type-sizes.rs
use std::mem::size_of;
fn main() {
println!("i32: {} bytes", size_of::<i32>());
println!("&i32: {} bytes", size_of::<&i32>());
println!("&mut i32: {} bytes", size_of::<&mut i32>());
println!("String: {} bytes", size_of::<String>());
println!("Vec<u8>: {} bytes", size_of::<Vec<u8>>());
}
在 Rust Playground 中运行

运行程序,输出如下:

运行输出
i32: 4 bytes
&i32: 8 bytes
&mut i32: 8 bytes
String: 24 bytes
Vec<u8>: 24 bytes

盯住这组结果里的两对类型:

  1. &i32 与 &mut i32:在内存里一模一样,都只占 8 个字节(一个指向栈上 i32 的内存地址);
  2. String 与 Vec<u8>(字节数组):在栈上也一模一样,都占 24 个字节(ptr、len、cap 三个机器字),堆上装的也都是连续的字节。

如果「类型 = 内存布局」,那内存布局完全相同的类型,行为就应该完全一样。但看看下面这两段真实代码:

同样是 8 字节地址,&i32 可以自由复制,&mut i32 赋值后原变量却会移动失效:

源文件:same-size-diff-rules.rs
// Uncomment the marked line to trigger error[E0382].
fn main() {
let x = 7;
let r1: &i32 = &x;
let r2 = r1;
println!("{r1} {r2}");
let mut y = 7;
let m1: &mut i32 = &mut y;
let m2 = m1;
// println!("{m1}");
println!("{m2}");
}
在 Rust Playground 中运行

前半段对 &i32 执行 let r2 = r1;,两个引用都能正常打印;后半段对同样占 8 字节的 &mut i32 执行 let m2 = m1;,一旦取消 // println!("{m1}"); 的注释,编译器立刻报出我们在第一篇熟悉的移动错误:

编译器诊断摘要:取消 same-size-diff-rules.rs 中标记行的注释后
error[E0382]: borrow of moved value: `m1`

同样是 24 字节控制头加堆上连续字节,String 却比 Vec<u8> 多了一道编码限制:

源文件:same-layout.rs
//! `String` and `Vec<u8>` have the same layout: pointer, length, capacity.
//! The difference is a rule: a `String` only accepts bytes that are valid UTF-8.
use std::mem::size_of;
fn main() {
println!("String: {}", size_of::<String>());
println!("Vec<u8>: {}", size_of::<Vec<u8>>());
let good = vec![104, 105]; // "hi"
let bad = vec![0xff, 0xfe]; // not UTF-8
println!("{}", String::from_utf8(good).is_ok()); // true
println!("{}", String::from_utf8(bad).is_ok()); // false
}
在 Rust Playground 中运行

Vec<u8> 里放任何字节(比如 0xff, 0xfe)都完全合法;但要把同样这串字节转成 String,却被当场拒绝(返回 false)——因为 String 要求堆上的字节在任何时刻都必须是合法的 UTF-8 编码。


类型的构成:物理表示 + 行为与安全约束

Section titled “类型的构成:物理表示 + 行为与安全约束”

上面两个例子把真相彻底亮了出来:只知道内存里长什么样,在 Rust 里连一行赋值能不能编译通过都判断不了。

在编译器的眼里,一个类型绝不仅仅是你写在源码里的那几个字母(比如 i32 或 String),而是编译器在编译期专门维护的一个完整信息集合体——我们把它叫作编译期类型实体(compile-time type entity)。每一个编译期类型实体,都由两个部分共同构成:

组成部分 回答什么问题 包含的具体信息
1. 物理表示(Representation) 这段数据在内存里怎么排? 1. 大小与对齐:占几个字节(如 i32 占 4 字节,String 占 24 字节),编译期大小是否已知;
2. 位模式解释:这些字节代表整数、内存地址,还是包含指针的控制头。
2. 行为与安全约束(Constraints) 对这段数据能做什么、谁负责? 1. 可调用的方法与行为:绑定了哪些函数与 Trait(对应 OOP 里的方法);
2. 所有权与释放责任:赋值时是走 Copy 还是走移动?离开作用域时要不要调用 Drop 释放堆内存?
3. 读写权限与不变量:允许只读共享(&T)还是独占修改(&mut T)?数据必须满足什么格式(如 String 的 UTF-8 约束)?
4. 有效区间(生命周期):内部包含的引用在哪一段代码区间内有效?

回看刚才那两对布局相同的类型,区别全在第二部分(行为与安全约束):

  • &i32 和 &mut i32 的物理表示都是 8 字节地址;但在编译期类型实体的约束表里,&i32 登记了「只读、可 Copy」,而 &mut i32 登记了「可写、禁止 Copy(赋值即移动)」。
  • String 和 Vec<u8> 的物理表示都是 24 字节控制头;但 String 的类型实体额外要求「内容必须为合法 UTF-8」。

讲到这里,我们可以再往底层追问一步:要让一个程序安全运行(知道数据有多大、能调什么方法、什么时候释放内存),总的信息量是守恒的。信息不会凭空消失,只会发生转移。

不同语言把这些信息存放在不同的地方。对比一下 Python/Java、C 与 Rust 的选择,就能看透 Rust 零成本抽象(Zero-Cost Abstractions)的物理根基。

1. Python 与 Java:把信息背在运行时对象头上

Section titled “1. Python 与 Java:把信息背在运行时对象头上”

在 CPython 中,连一个整数 1 都在堆上占 28 字节,其中前 24 字节全是引用计数与类型指针等对象头(Object Header)。

在 Java 中,栈上的引用变量整齐划一;而「这个对象是什么类型、锁状态是什么、有哪些方法可以调」的信息,全都住在运行时堆对象的对象头里(64 位 JVM 上每个对象额外多出 12 到 16 字节)。程序跑起来时,内存要为对象头付出空间代价,CPU 也要在运行时读取这些元数据。

2. C 语言:去掉对象头,但编译器也不管安全

Section titled “2. C 语言:去掉对象头,但编译器也不管安全”

C 语言去掉了运行时的对象头,内存紧凑。但它的类型几乎只管「占几个字节、怎么算偏移」;至于指针归谁释放、有没有悬空、能不能同时修改,类型系统一概不知,全靠写代码的人自己在脑子里记。

3. Rust:把信息搬进编译期,检查后彻底擦除

Section titled “3. Rust:把信息搬进编译期,检查后彻底擦除”

在 Rust 中,堆上的 "hello" 就是干干净净的 5 个字符字节,栈上的 i32 就是干干净净的 4 个整数字节——运行时内存里没有任何对象头,也没有任何标记「能不能修改、归谁释放、活多久」的附加位。

那些为了安全和抽象所必需的信息去哪了?全部转移到了编译器在编译期维护的编译期类型实体里(在接下来的章节中,只要没有跨语言对比的歧义,我们都将其简称为类型实体)。

编译器在编译阶段利用这些富信息完成借用检查、权限核对与方法地址绑定;一旦检查通过、生成机器码的那一刻,整套类型实体被彻底擦除,留给 CPU 的只剩下最纯粹的裸字节和直接的机器指令。

(如果你好奇 Python、Java、Go、C/C++ 与 Rust 在这套物理布局上的完整横向解剖,可选读附录:从「一切皆对象」到「一切皆类型」;关于 Rust 如何将 Java class 的六项职责拆回独立零件,也可参阅附录:没有 class 的对象感。)


这一节立住了整个第二篇的基础判断:

  1. 在 Rust 中一切皆 Type:没有 class 与基本类型的割裂,i32、String、&String、&mut i32 都是独立的编译期类型实体(以下简称类型实体);
  2. 类型实体 = 物理表示 + 行为与安全约束:表示决定内存怎么排,约束决定能调什么方法、走 Copy 还是移动、能不能改、由谁释放;
  3. 信息从运行时搬到了编译期:Rust 不靠运行时的堆对象头记录类型元数据,而是把表示与约束装进类型实体,核对完毕后在生成机器码时擦除——这就是零成本抽象的物理来源。

但顺着第 1 条往下想,一个让人好奇的问题马上就冒出来了:

既然 String、&String、&mut String 是三个完全不同的类型实体,那当我们自己在代码里写下 struct User 定义一个新结构体时,我们明明只定义了 User 这一个类型——请问 &User 和 &mut User 这两个类型实体,是在什么时候、由谁定义出来的?

带着这个问题,下一节我们揭开生产类型实体的机制——类型构造器。

继续:类型构造器 →