CH 02 · 世界观
编译与运行模型
C++ 程序从源码到运行,是一条"一条路走到机器码"的流水线。 C# 不是。它在中途岔开了一道:编译成中间语言,再由运行时在跑的时候翻译成机器码。
这一道岔,解释了 C# 的跨平台、反射、GC、JIT 优化、慢启动……几乎所有"运行时行为"。所以它必须先讲。
- C++ 和 C# 从源码到执行,各自的流水线长什么样?差别在哪一步?
- 什么是 IL、程序集、元数据?"托管代码"到底托管了什么?
- 为什么 C# 没有头文件?为什么它编译那么快?
- 部署时 framework-dependent / self-contained / AOT 分别是什么?
2.1 两条流水线,一次对照
C++ 的产物是"机器码";C# 的产物是"IL + 一份自我描述的说明书(元数据)"。 IL 不是给 CPU 看的,是给 CLR 看的。真正执行时,CLR 才把 IL 变成机器码。
2.2 回顾 C++ 侧:预处理 → 编译 → 链接
你熟悉这条链。挑几个"C# 里没有的"环节,因为正是它们的缺席造成了后面的差异:
| C++ 环节 | 作用 | C# 里的对应物 |
|---|---|---|
预处理 #include |
文本包含头文件,重复声明 | 没有。没有头文件、没有文本包含 |
#define / 宏 |
文本替换,可做元编程 | 只剩 #if 条件编译等少量指令,不能定义宏 |
| 编译单元 .cpp | 独立编译的最小单位 | 没有。文件之间不需要"声明"关系 |
| 链接器 | 把 .obj 拼成可执行文件、解析符号 | 由 CLR 在运行时按元数据解析类型与成员 |
| ABI 兼容 | 同一编译器 / 版本才能混用二进制 | IL 有统一规范,不同版本 / 语言可互通 |
为什么 C++ 编译那么慢,而 C# 通常快很多?点开看答案
C++ 慢,很大一部分不是"编译代码"慢,而是:
① 预处理:每个 .cpp 都要把它 include 的所有头文件(可能几万行)重新展开一遍;
② 模板实例化:模板在编译期展开,实例化爆炸时编译时间指数增长;
③ 优化:把机器码优化到极致要花很多时间。
C# 那边:没有文本包含(编译器直接读元数据)、泛型不在编译期展开(见第 06 章)、 而且 JIT 可以边跑边优化热点代码,把一部分优化成本挪到了运行期。
2.3 C# 侧:IL + 元数据 + CLR
IL(Intermediate Language,中间语言)
你写的 C# 被编译成一种类似汇编、但与具体 CPU 无关的指令集。它不是机器码,是"虚拟机的指令"。 你可以把它想成一套"所有平台都认识的通用指令"。
int Add(int a, int b) {
return a + b;
}
int Add(int a, int b) {
return a + b;
}
两者源码几乎一样。但编译后差异巨大 —— 下面是 C# 那行编译出的 IL
(示意,可用 ildasm / ilspy 查看):
.method int32 Add(int32 a, int32 b) {
.maxstack 2
ldarg.0 // 把参数 a 压入求值栈
ldarg.1 // 把参数 b 压入求值栈
add // 弹出两个数相加,结果压回栈
ret // 返回栈顶的值
}
IL 是基于栈的:它不直接操作寄存器,而是往一个"求值栈"上压 / 弹。 所以 IL 天生平台无关 —— 寄存器有多少、叫什么,是具体 CPU 的事,JIT 时才决定。
程序集(Assembly)与元数据(Metadata)
C# 编译产物叫程序集(.dll 或 .exe)。
注意:这里的 .dll 和 C++ 的 DLL 不是一回事 —— 它不是"动态链接库",
而是"装着 IL 和元数据的托管包"。
每个程序集里都带一份元数据:这个类叫什么、有哪些方法、参数类型是什么、引用了谁…… 相当于把 C++ 头文件里的信息,自动嵌进了二进制里。 这就是 C# 能反射、能跨语言、能不需要头文件的根本原因。
2.4 JIT 到底在做什么
程序启动时,CLR 加载程序集;方法第一次被调用时,JIT(Just-In-Time,即时编译) 把它对应的 IL 翻译成当前机器的机器码,之后就一直用这份缓存。
| 对比项 | C++(AOT / 提前编译) | C#(默认 JIT) |
|---|---|---|
| 编译发生时间 | 部署前,全部编译完 | 运行时,用哪个编哪个 |
| 启动速度 | 快(已经是机器码) | 略慢(要 JIT,首次调用有开销) |
| 峰值性能 | 取决于编译器优化 | 可针对当前 CPU 指令集优化,热点可反复优化 |
| 能否边跑边优化 | 不能 | 能(分层编译 / PGO) |
| 能否利用运行期信息 | 不能 | 能(知道真实类型、可做去虚拟化) |
不是。今天 .NET 提供了 AOT 编译(ReadyToRun、NativeAOT):可以提前把 IL 编译成机器码, 启动快、体积小、不需要用户装运行时。所以"C# 一定比 C++ 慢"是过时的印象 —— 真实情况取决于场景:长驻服务里 JIT 的优化甚至能反超静态编译; 而极低延迟、极短任务里原生 AOT 更合适。
2.5 "托管"(Managed)托管了什么
"托管代码"是 C# 的关键词。它不是营销词,指的是一系列由运行时替你负责的事情:
| 谁负责 | C++(非托管) | C#(托管) |
|---|---|---|
| 内存分配与释放 | 你(new / delete / 智能指针) | 运行时(GC 自动回收) |
| 类型安全 | 靠你自觉,可强转成任何东西 | 运行时 + 编译器强制检查 |
| 数组越界 | 不检查(UB) | 检查并抛 IndexOutOfRangeException |
| 对象生命周期 | 你精确控制(析构时机确定) | GC 决定(时机不确定) |
| 异常传播 | 可选、有代价,跨 ABI 麻烦 | 语言一等公民、统一处理 |
| 线程调度 / 并发原语 | 靠 OS API 或第三方库 | BCL 统一封装,且有 async/await |
| 程序集加载 / 版本 | 你自己搞定 DLL 与版本 | 运行时按清单解析、可并行加载多版本 |
托管换来安全与效率,但确实有代价:① 你无法精确控制对象何时销毁
(对文件句柄、数据库连接这类"稀缺资源"要用 IDisposable,见第 09 章);
② GC 会带来暂停(现代 GC 已非常优秀,但极端实时场景仍需小心);
③ 某些底层操作需要"逃出"托管世界
(用 unsafe / P/Invoke,见第 07 章)。
2.6 没有头文件的世界
这一条差异会显著改变你写代码的习惯,单独拎出来讲。
// --- math_utils.h (头文件:只声明)---
#pragma once
int Add(int a, int b);
// --- math_utils.cpp (源文件:定义)---
#include "math_utils.h"
int Add(int a, int b) { return a + b; }
// --- main.cpp (使用者:include)---
#include "math_utils.h" // 必须手动包含
int main() { return Add(1, 2); }
// MathUtils.cs (既是声明也是定义)
public class MathUtils {
public static int Add(int a, int b) {
return a + b;
}
}
// Program.cs (使用者:什么都不用包含)
int r = MathUtils.Add(1, 2);
// 只要在同一个项目里,编译器自己就能找到
| 概念 | C++ | C# |
|---|---|---|
| 声明 / 定义 | 必须分离,靠头文件共享 | 合一,写一次即可 |
| 引入别的文件 | #include 文本包含 |
不需要;同一项目自动可见 |
| 引入外部库 | #include 头 + 链接 .lib/.dll |
引用程序集(IDE 里点一下 / csproj 一行) |
| 避免重复定义 | #pragma once / include guard / inline |
天然不存在这个问题 |
| 命名冲突 | namespace + 头文件组织 |
namespace + using,且可起别名 |
| 一个类分成多份 | 不行(一个类通常写在一个文件) | 可以:partial class 分部类 |
C# 允许把一个类拆到多个文件里,用 partial 标记,编译时自动合并。
这在"自动生成的代码 + 手写的代码"要放在同一个类里时特别有用
(比如界面设计器生成的代码 + 你的业务逻辑)。C++ 里没有直接对应物。
2.7 部署模型:三种发布方式
C++ 里你纠结的是"静态链接还是动态链接";C# 里对应的是这三种:
| 方式 | 含义 | 目标机器需要什么 | 典型场景 |
|---|---|---|---|
| 框架依赖 framework-dependent |
只发布你的程序,运行时由系统的 .NET 提供 | 要装 .NET Runtime | 服务器、内部工具(体积小) |
| 自包含 self-contained |
把运行时和你的程序一起打包 | 什么都不用装 | 发给普通用户的桌面软件(体积大,几十 MB 起) |
| NativeAOT | 发布前就编译成原生机器码 | 什么都不用装,也没 JIT | 追求启动速度 / 体积 / 无运行时的场景 |
C++ 里"动态链接运行库"意味着用户机器上要有对应的 VC++ Redistributable, 版本不对就报"缺少 xxx.dll"。C# 的自包含发布把这类问题从根上消掉了 —— 依赖跟着程序走,不会互相污染。这正是当年解决 DLL 地狱思路的延续。
小测验
Q1:C# 的 .dll 和 C++ 的 .dll,是一回事吗?点开看答案
不是。C++ 的 .dll 是"动态链接库" —— 一堆可直接执行的机器码 + 导出表,
由操作系统加载器加载,受 ABI 约束。
C# 的 .dll 是"程序集" —— 里面装的是 IL + 元数据,不是机器码,由 CLR 加载,不受平台 ABI 约束。
两者扩展名一样,本质完全不同。容易混,记住这一条。
Q2:同一个 C# 程序,在 Intel 机器和 ARM 机器上,是用同一份文件跑吗?点开看答案
如果发布的是框架依赖 / 自包含(非 AOT)版本:是的,同一份程序集(IL)可以在两种 CPU 上运行
—— 因为 IL 是平台无关的,各自平台的 CLR 会把它 JIT 成各自的机器码。
如果用了 NativeAOT / ReadyToRun:那就要为每个目标平台(RID)分别发布,
因为里面已经是特定平台的机器码了。
这刚好对应 C++ 里"源码跨平台 vs 二进制跨平台"的区别。
Q3:为什么 C# 能"反射"(在运行时知道一个对象有哪些方法和字段),C++ 默认不行?点开看答案
因为 C# 编译产物里带着完整的元数据(类型名、成员、参数、特性……),运行时随时可以查。 C++ 编译成机器码后,这些"结构信息"大多丢失了(除非开 RTTI,但 RTTI 也只提供很有限的信息)。 没有元数据,就没有反射、没有自动序列化、没有依赖注入 —— 而这些是 C# 生态的日常。
记住这条分界线:C++ 的复杂度花在"编译期",C# 的复杂度花在"运行期"。 后面几章几乎都在展开它的后果 —— 内存交给 GC(第 03 章)、类型系统分值与引用(第 04 章)、 泛型不在编译期展开(第 06 章)、稀缺资源要靠 Dispose(第 09 章)。