跳转到内容

C.1 从「一切皆对象」到「一切皆类型」:五门语言的类型与内存光谱

在编译期类型实体一节中,我们提出了一个对比:面向对象(OOP)语言常在运行时强调「一切皆对象(Everything is an Object)」,而 Rust 在编译期贯彻的是「一切皆类型(Everything is a Type)」。

不同语言背景的读者看到这句话,脑子里调出来的参照系其实各不相同:

  • 如果你写 Python,你会好奇:Python 连整数 1 都是对象,这在内存里到底付出了什么代价?
  • 如果你写 Java,你会好奇:Java 为什么把 int 基本类型和 class 对象劈成两半,却又在泛型里逼你把 int 装箱成 Integer?
  • 如果你写 C / C++ 或 Go,你的结构体本来就没有 Java 那样的对象头,那 Rust 的「编译期类型实体」跟 C/C++ 和 Go 的类型又有什么本质区别?

这篇附录把 Python、Java、C/C++、Go 与 Rust 放在同一张解剖台上,用两件最具体的东西——一个整数 1,以及一个包含两个 32 位整数的二维坐标 Point { x: 1, y: 2 }——横向看清五门语言在「运行时内存」与「编译期类型」之间的取舍光谱。


第一站:Python——把「一切皆对象」推向极致的运行时世界

Section titled “第一站:Python——把「一切皆对象」推向极致的运行时世界”

Python 对「一切皆对象」的贯彻最为彻底:在 Python 里,没有任何「基本类型」与「对象」的区分,哪怕你只是写下一行最简单的整数赋值:

Python 对照代码
import sys
x = 1
print(sys.getsizeof(x)) # 64 位 CPython 输出:28

明明只是一个整数 1(在硬件寄存器里 4 个字节就够装了),为什么在 64 位 CPython 里却占了 28 个字节?

因为在 CPython 的运行时里,所有的变量都只是指向堆上 PyObject 结构体的指针。为了践行「运行时一切皆对象」,这个堆上的整数结构体头上必须顶着沉重的对象头(Object Header):

  1. ob_refcnt(8 字节):运行时的引用计数器,每次赋给新变量就加 1,离开作用域就减 1;
  2. ob_type(8 字节):指向类型对象(&PyLong_Type)的指针,运行时靠它知道「我是一个整数」以及该去哪里找加法、打印等方法;
  3. ob_size(8 字节):记录变长大整数内部用了几个数组单元;
  4. ob_digit(4 字节):最后这 4 个字节,才真正装着数字 1 本身。

为了4个字节的有效数据,背上了 24 个字节的运行时元数据。这就是把所有类型与内存管理信息全部留到运行时对象头里所付出的物理代价。


第二站:Java——在「基本类型」与「对象」之间劈叉的妥协

Section titled “第二站:Java——在「基本类型」与「对象」之间劈叉的妥协”

Java 作为工业界最主流的 OOP 语言,当年同样喊着「一切皆对象」出道;但为了避免像 Python 那样连算个 1 + 1 都要去堆上查 28 字节的对象,Java 做出了一个影响深远的妥协——把类型系统一刀劈成两半:

  1. 第一半是 8 个基本类型(Primitive Types):int、long、double、boolean 等。它们不是对象,没有方法,直接以 4 字节或 8 字节裸值放在栈上。
  2. 第二半是类与对象(class / Object):只要你用 class 定义一个 Point { int x; int y; },它就必须在堆上分配,并且每个实例头上都要顶着 12 到 16 字节的对象头(Object Header)(包含 8 字节的 Mark Word 记录锁与 GC 状态,以及 4~8 字节的 Klass Word 指向类元数据)。

这种二元割裂带来了两个后果:

  • 认知上的错觉:Java 开发者习惯把「Type(类型)」狭义地等同于 int、float 那 8 个基本类型,而把复杂的业务抽象全归功于 class。
  • 泛型与容器里的装箱成本:因为 Java 的泛型(如 ArrayList<T>)在编译后会擦除为只认对象引用的 Object,基本类型 int 根本塞不进泛型容器,必须包装成堆上的 Integer 对象(16~24 字节)。如果你在 Java 里创建一个包含 100 个 Point 的数组 Point[],栈上是指向数组的引用,数组在堆上存着 100 个 8 字节指针,这 100 个指针再分别指向堆上 100 个各自顶着 12 字节对象头的 Point 实例——内存碎片与间接寻址成本成倍放大。

第三站:C / C++ 与 Go——扒掉对象头之后,安全信息放哪?

Section titled “第三站:C / C++ 与 Go——扒掉对象头之后,安全信息放哪?”

既然堆上的对象头这么贵,系统级语言的第一反应自然是:把对象头扒掉,让 Point { x, y } 回归纯粹的 8 字节(两个 4 字节整数挨在一起)!

C、C++、Go 和 Rust 全都这么做了。但在扒掉对象头之后,「谁负责释放内存、指针还安不安全、能不能同时修改」这些信息去哪了?这四门语言走上了三条不同的岔路:

1. C 语言:类型只管内存布局,安全规则全靠人脑记

Section titled “1. C 语言:类型只管内存布局,安全规则全靠人脑记”

在 C 语言中,struct Point { int x; int y; } 确实只占 8 字节。但 C 语言的 type 几乎只做一件事:告诉编译器占几个字节、字段偏移量是多少。 至于一个指针 struct Point *p 指向的是栈还是堆、由谁负责 free、指向的数据有没有被提前释放,C 语言的类型系统一概不记录,全部交给程序员用人脑去记。

2. Go 语言:去掉 class 与对象头,但把释放与共享兜底交给运行时 GC

Section titled “2. Go 语言:去掉 class 与对象头,但把释放与共享兜底交给运行时 GC”

Go 同样没有 class 关键字,只有 type Point struct { x, y int32 },100 个 Point 的数组也是连续紧挨的 800 字节裸数据,比 Java 紧凑得多。 但当涉及指针共享与内存释放时,Go 选择不把「唯一所有者」「借用排他性」写进类型系统,而是保留了运行时的追踪式垃圾回收器(GC)。如果编译器在逃逸分析中发现一个局部变量的指针被外部拿走且算不清存活期,就会自动把它搬到堆上交给 GC 扫描。

3. C++:拥有零成本抽象与 RAII,但引用/指针类型缺少编译期有效期与排他约束

Section titled “3. C++:拥有零成本抽象与 RAII,但引用/指针类型缺少编译期有效期与排他约束”

C++ 与 Rust 在物理内存布局上最为接近:值默认内联、没有对象头、利用析构函数(RAII)在离开作用域时自动释放资源、利用模板在编译期生成专属机器码。 但 C++ 的历史包袱在于:它的引用(Point&)和指针(Point*)在编译期的类型信息里,没有携带「读写排他约束」与「生命周期有效区间参数」。在 C++ 里,你可以合法地同时持有指向同一个 std::vector 的可变引用和只读元素引用,然后在追加元素导致扩容时把只读引用变成悬垂指针——编译器不会报错,运行时直接踏入未定义行为。


第四站:Rust——编译期「一切皆类型」,运行时「只留裸字节」

Section titled “第四站:Rust——编译期「一切皆类型」,运行时「只留裸字节」”

现在把镜头拉回 Rust,就能看清它为什么在这条光谱上显得独树一帜。

在运行时内存这一端,Rust 站在了和 C / C++ 一样的极致紧凑位置——彻底扒掉对象头:

源文件:bare-bytes.rs
use std::mem::size_of;
struct Point {
x: i32,
y: i32,
}
fn main() {
let p = Point { x: 1, y: 2 };
println!("Point ({}, {}): {} bytes", p.x, p.y, size_of::<Point>());
println!("[Point; 100]: {} bytes", size_of::<[Point; 100]>());
}
在 Rust Playground 中运行

运行输出:

运行输出
Point (1, 2): 8 bytes
[Point; 100]: 800 bytes

一个 Point 就是实打实的 8 字节,100 个 Point 的数组就是连续紧挨的 800 字节,没有任何对象头,也没有任何额外的指针跳转。

但为了在不引入 GC、也不退回 C/C++ 手工防悬垂的前提下保证绝对安全,Rust 把其他语言放在「运行时对象头」「GC」或「程序员脑子」里的所有信息,统统锻造成了编译期的类型实体:

  1. 权限成了不同的类型:在 C++ 或 Go 里,只读和修改往往共用同一种指针或靠 const 限定符修饰;而在 Rust 里,&Point(可复制的共享只读视图)与 &mut Point(不可复制的独占可变视图)直接是两个由不同构造器生成的独立类型实体。
  2. 存活区间成了类型的参数:内层短命的 &'short Point 与外层长命的 &'long Point,在编译期是携带不同生命周期参数的两个类型实体。
  3. 连函数和闭包都有各自专属的编译期类型实体:在 Java 或 Python 里,一个匿名函数(Lambda / 闭包)通常是一个运行时的函数对象;而在 Rust 里,每当你写下一个闭包,编译器就在编译期为这一个闭包单独合成一个匿名的结构体类型实体,它捕获了什么变量,它的大小就是多少字节:
源文件:everything-is-type.rs
use std::mem::size_of_val;
fn main() {
let base = 10;
let c1 = |x: i32| x + 1;
let c2 = |x: i32| x + base;
println!("closure (no capture): {} bytes", size_of_val(&c1));
println!("closure (capture &i32): {} bytes", size_of_val(&c2));
}
在 Rust Playground 中运行

运行输出:

运行输出
closure (no capture): 0 bytes
closure (capture &i32): 8 bytes

第一个闭包 c1 没有捕获任何外部变量,编译器为它生成的专属类型实体在运行时占 0 字节;第二个闭包 c2 借用了外部的 base(&i32),它的类型实体在栈上就只占这一个引用的 8 字节。


把这五门语言放在一张表里,整个演进脉络一清二楚:

语言 核心口号 / 范式 整数 1 的运行时表示 Point { x, y } 的表示 安全与类型元数据的存放位置
Python (CPython) 运行时一切皆对象 堆上 PyObject(28 字节,含 24 字节对象头) 堆上对象 + 字典/槽位(数十至上百字节) 全部在运行时堆对象头(引用计数 + 类型指针)
Java 基本类型与 class 二元割裂 int 栈上 4 字节;装箱 Integer 堆上 16~24 字节 堆上对象(8 字节字段 + 12~16 字节 Object Header) 运行时堆对象头 + 后台追踪式 GC
Go 无 class 结构体 + 运行时 GC 栈/堆上裸 4 或 8 字节 连续 8 字节裸数据(无对象头) 编译期静态布局 + 运行时 GC 兜底释放(接口使用双字胖指针)
C / C++ 零对象头 + 手工/RAII 栈/堆上裸 4 字节 连续 8 字节裸数据(无对象头) 编译期管布局与析构;别名排他与悬垂防护留在程序员脑子里
Rust 编译期一切皆类型,运行时只留裸字节 栈/堆上裸 4 字节 连续 8 字节裸数据(100 个即 800 字节,零对象头) 全部装进编译期类型实体(表示 + 所有权/排他/生命周期约束),编译后彻底擦除

带走这幅全景图,再回看第二篇的每一章,你就不会再问「为什么 Rust 要花这么大篇幅讲类型」——因为在别的语言里交给运行时对象头、交给垃圾回收器、或者交给程序员自己祈祷不出错的那些事,在 Rust 里,全都变成了类型本身。