C# 和 C++ 名字里有同一个字母,语法也都用大括号,但它们处在不同的层次。C++(1985 年)直接编译成机器码,让你精确控制每个对象存放在哪里、何时销毁。C#(2002 年)编译成中间语言,由 .NET 运行时在运行时转换为机器码,还有垃圾回收器帮你回收内存。下面几乎所有的区别都源于这一个选择。
一览
| C# | C++ | |
|---|---|---|
| 执行方式 | 托管:IL,由 CLR 进行 JIT 编译 | 本机:提前编译为机器码 |
| 内存 | 垃圾回收器 | 手动管理、RAII、智能指针 |
| 指针 | 引用;原始指针只能用在 unsafe 代码中 | 原始指针和引用随处可用 |
| 安全性 | 数组有边界检查,没有悬空引用 | 越界、释放后使用都是未定义行为 |
| 构建方式 | 项目和程序集,没有头文件 | 头文件、预处理器、翻译单元、链接器 |
| 泛型 | 泛型,只检查一次,在运行时解析 | 模板,在编译时实例化 |
| 标准库 | 庞大:集合、HTTP、JSON、文件、线程 | 较小:容器、算法、线程 |
| 编译速度 | 快 | 大型代码库很慢 |
| 游戏 | Unity、Godot | Unreal、大多数自研 AAA 引擎 |
| 其他主要用途 | Web 后端、桌面、云、工具 | 引擎、浏览器、操作系统、嵌入式、交易系统 |
内存:垃圾回收器与 RAII
在 C++ 中,自动存储的对象在离开作用域时被销毁,它的析构函数恰好在那一刻运行。堆上的对象由智能指针(std::unique_ptr、std::shared_ptr)持有,或者用 new 和 delete 手动管理。这种模式叫“资源获取即初始化”(RAII),它让内存和其他所有资源的清理都是确定性的。
// C++
#include <memory>
#include <fstream>
void save() {
std::ofstream file("log.txt"); // opened here
auto buffer = std::make_unique<char[]>(4096);
file << "saved\n";
} // file closed and buffer freed here, in reverse order
在 C# 中,每个类实例都存放在托管堆上,等到没有任何东西引用它之后,垃圾回收器会在之后的某个时间释放它。你从不需要写 delete,也永远不可能释放仍在使用的东西。内存已经有人管了;垃圾回收器不会及时处理的是其他资源:文件、套接字、数据库连接。为此,C# 提供了 IDisposable 和 using 语句,它会在代码块结束时调用 Dispose,这是 C# 中最接近析构函数的东西:
输出:
open db
open cache
db <- SELECT 1
cache <- PING
close cache
close db
done
区别在于:在 C++ 中,每个局部对象和每个由智能指针持有的对象,其清理都与作用域绑定;而在 C# 中,内存的清理是自动的,其他资源则需要你主动选择(通过 using)。更多内容见 using 语句 页面。
安全性:用异常代替未定义行为
在 C++ 中读取数组末尾之后的内容是未定义行为:程序可能打印出乱码、崩溃,或者带着损坏的内存继续运行,而且结果在不同的构建之间可能不一样。同样的错误在 C# 中会在出错的那一行抛出异常。
// C++: compiles, and the behavior is undefined
int scores[3] = {90, 85, 77};
int x = scores[5]; // reads whatever is in memory there
输出:
Index 5 is outside an array of length 3
name was null
在安全的 C# 中,整整一类内存损坏 bug(缓冲区溢出、释放后使用、重复释放、悬空指针)根本不存在。这在很大程度上解释了为什么编写和审查 C# 代码更快。
指针与不安全代码
C# 确实有指针,但只能用在 unsafe 块中,而且项目必须通过 <AllowUnsafeBlocks>true</AllowUnsafeBlocks> 主动开启。托管堆上的对象在垃圾回收期间可能会移动,所以取地址之前要先用 fixed 把它们固定住:
// C#, requires AllowUnsafeBlocks
unsafe
{
int[] data = { 1, 2, 3 };
fixed (int* p = data)
{
*(p + 1) = 20; // data is now { 1, 20, 3 }
}
}
不安全代码用于和本机库互操作,以及少数性能关键的循环。如今大多数底层 C# 代码改用 Span<T>、ref 局部变量和 stackalloc,它们能提供接近指针的性能,同时保留边界检查。
性能
C++ 让编译器提前拿到整个程序,运行时不增加任何东西:没有垃圾回收器,没有 JIT,除非你要求,也没有边界检查。所以在每一微秒、每一个字节都很重要、不能容忍停顿的场景中,C++ 是首选。
C# 为它的安全性付出的代价是一个运行时、启动时的 JIT 预热,以及偶尔的垃圾回收暂停。在吞吐量上,它通常与 C++ 相差不大,而且每个 .NET 版本都在缩小差距:带配置文件引导优化的分层 JIT、用结构体和 Span<T> 避免内存分配、用于 SIMD 的硬件内部函数,以及 Native AOT 提前编译成单个本机可执行文件。对于 Web API、工具和业务逻辑来说,耗时主要在数据库和网络上,语言差异很少显现出来。
游戏开发:Unity 与 Unreal
大多数人正是在这里遇到这个问题。Unity 用 C# 编写脚本:游戏逻辑代码、UI 和工具都是挂在游戏对象上的 C# 类,而引擎核心是 C++。Unreal Engine 用 C++ 编写,游戏逻辑用 C++ 加上 Blueprints 可视化脚本系统。Godot 同时支持 GDScript 和 C#。
C# 配合 Unity 学得更快、迭代更快,所以它在独立游戏和手机游戏中非常普遍。Unreal 和 C++ 是 AAA 工作室的标准,各地的引擎程序员都用 C++ 工作。一条常见的路线是从 Unity 起步,转向 Unreal 或引擎开发时再学 C++。
构建方式
C++ 程序分为头文件(.h,放声明)和源文件(.cpp,放定义)。预处理器把头文件粘贴到每个源文件中,每个文件单独编译,再由链接器把结果连接起来。模板会在每个使用它的文件中实例化,这是大型 C++ 项目构建缓慢的原因之一。
C# 没有这些。一个项目就是一组 .cs 文件,一起编译成一个程序集(.dll);声明顺序和文件顺序都无关紧要,一个文件中的类型无需 include 就能使用另一个文件中的类型。库以 NuGet 包的形式提供。using 指令导入的是命名空间,而不是文件。
你会注意到的语法差异
- 对象。 C++ 中是
auto p = std::make_unique<Player>();和p->Jump();;C# 中是var p = new Player();和p.Jump();。C# 一律用.。 - 字符串。
std::string是可变的值;C# 的string是不可变的引用类型。 - 多重继承。 C++ 允许一个类继承多个类;C# 只允许一个基类,外加任意数量的接口。
- 模板与泛型。 C++ 模板是编译期的代码生成,可以做元编程;C# 泛型只做一次类型检查,用显式约束(
where T : IComparable<T>)。 - 标准库。 C# 的标准库包含 HTTP、JSON、正则表达式、文件 IO、压缩和加密;在 C++ 中,这些很多都来自第三方库。
学哪一个
如果你想开发应用、Web 后端、工具或 Unity 游戏,并且很快看到成果,就学 C#。如果你的目标是游戏引擎、图形、嵌入式系统、操作系统、浏览器,或者任何需要硬件级控制和可预测延迟的领域,就学 C++。C# 作为第一门语言更友好;C++ 值得作为第二门语言来学,因为它能让你明白 C# 运行时在背后为你做了什么。
常见问题
C# 和 C++ 最主要的区别是什么?
C# 是托管语言:它编译成中间语言,由 .NET 运行时进行 JIT 编译,并由垃圾回收器释放内存。C++ 直接编译成机器码,由你控制对象何时创建、何时销毁。这种取舍让 C++ 拥有更多控制力和可预测性,让 C# 更安全、开发更快。
C# 比 C++ 简单吗?
对大多数人来说是的。C# 没有手动内存管理,没有头文件,安全代码中没有未定义行为,编译错误也更清楚。C++ 是一门更庞大的语言,有更多途径犯下编译器发现不了的隐蔽错误,比如悬空指针、缓冲区溢出和释放后使用。
C++ 比 C# 快吗?
写得好的 C++ 通常更快,更重要的是更可预测,因为没有垃圾回收暂停,也没有 JIT 预热。现代 C# 借助结构体、Span<T>、SIMD 和提前编译缩小了差距,对于业务应用来说,语言差异很少成为瓶颈。在引擎、驱动程序和高频交易领域,C++ 仍然是标准。
做游戏开发应该学 C# 还是 C++?
做你的第一批游戏,用 C# 配合 Unity(或 Godot)能更快地完成和发布。Unreal Engine 使用 C++(加上 Blueprints),编写引擎代码的 AAA 工作室招的是 C++ 程序员。很多开发者从 Unity 和 C# 起步,需要做引擎层面的工作时再学 C++。
C# 有指针吗?
有,在 unsafe 代码块中,而且必须通过项目设置 AllowUnsafeBlocks 启用。普通的 C# 使用引用,引用由垃圾回收器跟踪,不可能指向已释放的内存。在大多数你会想用指针的场景中,ref、Span<T> 和 stackalloc 就够用了。