为什么 C++ 工程师总会在两种极端之间摇摆
为什么 C++ 工程师总会在两种极端之间摇摆
C with Classes, Template Metaprogramming,and Everything is RAII
在 C++ 社区待得足够久,你会发现一个有趣的现象。
很多工程师职业生涯早期热衷于面向对象设计:
class Texture : public Resource {
};
后来开始学习现代 C++:
std::unique_ptr
std::optional
std::variant
concept
再后来,他们中的一些人突然开始写出这样的代码:
struct Texture {
uint32_t width;
uint32_t height;
};
void upload_texture(Texture* texture);
仿佛又回到了 C 语言。
与此同时,另一部分工程师则走向另一个方向:
template<
typename Allocator,
typename Policy,
typename Traits
>
class ResourceManager;
模板、Concept便是一切。
为什么会这样?为什么同一种语言会不断把人推向两个完全不同的方向?
C++ 同时拥有两个世界
大多数语言都会限制程序员的自由度。这种约束有时会令人不适,但它带来的好处是:整个社区会逐渐收敛到相似的工程实践。Rust 不允许随意进行内存操作。Java 不允许直接管理对象生命周期。Go 不允许进行复杂泛型元编程,以及最出名的Python 刻度尺语法。
这些语言都在试图回答同一个问题:什么样的代码是“正确”的代码?语言设计者已经替开发者做出了大量选择。因此,在 Java 社区中,即使两个团队互不认识,他们写出的代码风格通常也不会相差太远。
而 C++ 几乎什么都允许。你既可以这样写:
void* memory = malloc(size);
也可以这样写:
template<typename T>
concept Serializable = requires(T value) {
serialize(value);
};
你可以选择:手动管理生命周期、RAII、Garbage Collection、Arena Allocator、引用计数、Handle Table
你也可以选择:面向对象、泛型编程、数据导向设计(DOD)、函数式风格、ECS
甚至可以在同一个项目中同时使用这些模式。
从某种意义上说,C++ 并不是一种编程语言——它更像是一个工具箱——语言本身提供机制(Mechanism),却很少提供策略(Policy)。这种自由度带来了极强的表达能力,但代价同样明显。
对于很多语言而言,社区争论的问题通常是:如何正确使用这门语言?而 C++ 社区争论的问题却是:什么才算正确?
于是你会看到截然不同的观点同时存在。
有人认为:
struct Texture {
uint32_t width;
uint32_t height;
};
已经足够。
有人则认为:
template<typename Allocator,
typename Policy,
typename Traits>
class Texture;
才是现代 C++。
两者都可以写出优秀的软件,也都能找到大量成功案例,甚至都能引用同一本《The C++ Programming Language》作为依据。因此,C++ 社区始终无法形成统一风格,不像 Java 那样逐渐收敛,反而会随着经验积累不断分化。最终,每个工程师都会根据自己解决过的问题、踩过的坑以及所处的领域,形成属于自己的 C++。
Java 社区讨论的是最佳实践(Best Practice),而 C++ 社区讨论的是实践本身(Practice)。这也是为什么 C++ 工程师之间的代码风格差异,往往比不同语言之间的差异还大。
为什么很多人最终变成 C with Classes
许多 C++ 工程师职业生涯的起点都相似:他们学习面向对象设计,学习设计模式,学习继承、多态与封装。然后构建出这样的系统:
Entity
├── Transform
├── MeshRenderer
├── Animator
└── ...
对象通过指针彼此引用。一切看起来如此美妙。
直到项目开始变大。
最开始出现问题时,人们通常怀疑算法。是不是复杂度太高?是不是有多余计算?于是优化算法。性能提升了一些。但仍然不够。
接下来开始怀疑语言特性。是不是虚函数太慢?是不是 shared_ptr 太重?是不是异常机制影响性能?于是:virtual 被消灭、shared_ptr 被替换、exception 被禁用,性能再次提升了一些,但依然没有达到预期。
继续分析:
- 打开 Profiler
- 查看 CPU Pipeline
- 观察 Cache Miss
这时很多人才第一次意识到:CPU 真正处理的并不是对象,而是内存。对于处理器而言:
entity->transform->position
并不是什么优雅的对象模型。
而是:读取指针、跳转内存、读取指针、跳转内存、读取数据。每一次跳转都有可能导致 Cache Miss。每一次 Cache Miss 都可能比一次函数调用昂贵数十倍甚至数百倍。于是工程师开始重新审视自己的抽象。他们发现:问题并不在于代码是否优雅,问题在于数据是否连续,于是关注点开始从对象转移到数据。
从:
std::vector<Entity*>
变成:
std::vector<Position>
std::vector<Velocity>
从 Array of Structures(AoS)变成 Structure of Arrays(SoA),从继承树变成组件数组,从对象图变成 Handle Table。
从:
entity->update()
变成:
for (...)
{
position[i] += velocity[i];
}
代码越来越不像传统意义上的面向对象程序,反而越来越接近 C。但这并不是退步。事实上,这往往意味着工程师开始理解机器真正的运行方式。他们不再仅仅关注源代码层面的抽象,而开始关注:
- Cache Hierarchy
- Branch Prediction
- SIMD
- Memory Bandwidth
- Data Locality
此时,程序设计的核心问题已经从:“对象应该如何组织?”逐渐转变为:“数据应该如何流动?”
这也是为什么许多游戏引擎、数据库、物理引擎以及高频交易系统,最终都会表现出明显的 “C with Classes” 风格。
它们并不是拒绝抽象,而是在性能敏感区域,优先服从硬件规律。
为什么很多人走向模板宇宙
如果说 C with Classes 派是在不断接近硬件,那么模板派则是在不断追求抽象,而且这种追求并非毫无道理。
软件工程本质上是在管理复杂度,当项目规模不断扩大时,工程师会自然地产生一个需求:能否把重复逻辑抽象掉?
例如:
save_png(image);
save_jpg(image);
save_bmp(image);
最终会变成:
save<ImageFormat>(image);
问题在于:传统的抽象手段往往伴随着运行时成本。
例如:
class Serializer {
public:
virtual void serialize() = 0;
};
虽然灵活,但需要:vtable、间接跳转、动态分发。对于性能敏感场景而言,这些成本并不总是可以接受。
于是模板出现了。
它提供了一种极具诱惑力的能力:运行时零成本抽象(Zero-cost Abstraction)。工程师第一次发现:抽象与性能似乎不再是对立关系。既然 virtual 会产生运行时成本。那么:
template<typename T>
是否可以替代它?
于是抽象开始不断向编译期迁移。
最开始只是简单泛型:
template<typename T>
class Vector;
随后演变为:
- Traits
- SFINAE
- CRTP
- Policy Based Design
- Expression Template
- Concepts
- Meta Programming
越来越多的逻辑开始在编译期完成,以至于最终形成一种完全不同的设计哲学。
在这种体系下:运行时程序只是结果,真正的软件架构存在于类型系统之中。
例如:
Renderer<
D3D12Backend,
LinearAllocator,
DeferredPipeline,
MultiThreadScheduler
>
这里描述的已经不仅仅是一个对象,而是整个系统的组成方式。这种设计拥有极其强大的能力。它可以:
- 消除虚函数
- 消除分支判断
- 自动生成代码
- 在编译期验证约束
- 将错误提前到编译阶段
对于大型库和基础设施而言,这些优势非常有吸引力,但代价同样存在。随着越来越多逻辑进入编译期:
- 编译器逐渐成为系统的一部分。
- 工程师不仅需要理解程序如何运行。
- 还需要理解程序如何被生成。
于是出现:
- 编译时间急剧增长
- 错误信息指数膨胀
- IDE 响应变慢
- 调试难度增加
- 学习曲线陡峭
很多模板代码的问题并不在于运行速度,而在于人类已经难以理解它。
因此,模板宇宙本质上并不是对性能的追求——性能只是最初的入口。
它真正追求的是另一件事:用类型系统表达系统结构。当这种思想发展到极致时,程序不再由对象驱动,也不再由数据驱动,而是由类型驱动。
对于模板派工程师而言:代码不仅是在告诉编译器“应该做什么”。更是在告诉编译器“什么是合法的系统”。这也是为什么 C++ 社区中最激进的抽象设计,几乎都诞生于模板世界。
RAII 为什么容易变成信仰
在 C++ 社区,很少有设计思想像 RAII 一样受到广泛认可。
它几乎完美地解决了资源泄漏问题。
例如:
{
std::lock_guard lock(mutex);
// ...
}
离开作用域自动释放资源。
简单、可靠、异常安全,几乎无可挑剔。
正因为如此,很多工程师会自然地将这种思想推广到整个系统。
- 文件使用 RAII
- Socket 使用 RAII
- 线程使用 RAII
- 数据库连接使用 RAII
然后:
- Buffer 使用 RAII
- Descriptor Set 使用 RAII
- Pipeline 使用 RAII
最终,一切资源都被包装成对象。
问题在于:
并非所有资源都天然拥有“作用域生命周期”。
对于文件而言:
{
File file("data.bin");
}
作用域结束,文件关闭,这是符合直觉的。但其他资源(比如 GPU 资源)往往不是这样。
例如一个 Texture,从 CPU 视角看。对象可能已经离开作用域,但 GPU 仍然可能在未来数帧内继续访问它。
现代图形系统普遍存在:
- Frame In Flight
- 异步命令提交
- GPU / CPU 并行执行
- 资源热重载
- Streaming
- 延迟销毁
这些机制意味着:CPU 认为资源已经死亡,GPU 却认为资源仍然活着。
于是开发者开始发现:真正需要管理的并不是对象,而是资源状态。
例如:
- Created
- Resident
- Referenced
- Pending Destroy
- Destroyed
这更像一个状态机,不是么?
为了表达这种生命周期,很多引擎重新引入:
- TextureID
- MeshID
- BufferID
- ResourceHandle
资源不再直接暴露,而是通过句柄访问——实际资源存放在 Resource Manager 中。
销毁也不立即发生,而是进入延迟回收队列,等待 GPU 完成使用后再释放。
于是一个有趣的现象出现了,很多工程师职业生涯早期会相信:
RAII 是解决资源管理问题的最终答案。
而在接触图形引擎、数据库、网络服务器等大型系统后。他们逐渐发现:
RAII 解决的是作用域生命周期问题。
而资源管理解决的是系统生命周期问题。两者经常重合,但并不总是相同。
因此真正成熟的工程实践通常不是抛弃 RAII,而是明确它的适用边界。
对于:
- Mutex
- File
- Temporary Buffer
- Scoped State
RAII 几乎是最佳选择。
而对于:
- GPU Resource
- Asset System
- Streaming Data
- Distributed Object
句柄与资源管理器往往更加自然。
RAII 最大的成功,也是它最大的陷阱。它在许多场景下工作得过于完美,以至于人们容易产生一种错觉:既然 RAII 能管理资源,那么所有资源都应该变成对象。
而大型系统最终会证明:
有些东西适合作为对象存在。
有些东西更适合作为资源存在。
这两者并不是同一个概念。
真正成熟的工程往往不在两端
如果观察大型游戏引擎、数据库、浏览器或者操作系统,会发现一个有趣的现象。
这些项目的代码风格往往并不统一,甚至可以说完全不统一。
同一个项目中,你可能同时看到:
struct Handle {
uint32_t index;
uint32_t generation;
};
以及:
template<typename Allocator>
class Vector;
还有:
class ScopedLock {
public:
ScopedLock(Mutex&);
~ScopedLock();
};
它们分别代表着完全不同的设计哲学,但却能够长期共存。原因在于:
不同层次面对的问题并不相同,因此需要的抽象强度也不相同。
最底层通常最接近硬件。
这里关注的是:
- Cache Locality
- Memory Layout
- SIMD
- Memory Bandwidth
- Synchronization
在这一层,抽象成本会被无限放大,因此代码往往表现出明显的 C with Classes 风格:
struct Position {
float x;
float y;
float z;
};
- Arena Allocator。
- Handle Table。
- POD Structure。
- 连续内存。
- 显式生命周期。
这里代码是否优雅并不重要。
中间层开始承担系统组织职责。
这里需要:
- 泛型算法
- 容器
- 任务系统
- 资源管线
于是模板开始出现,但通常会保持克制。
模板被用于表达通用逻辑,而不是将整个系统变成编译期谜题。这一层追求的是:抽象带来的收益必须大于理解成本。
而在高层。
性能往往不再是主要矛盾。开发效率和维护成本变得更加重要。
于是:
- RAII
- Reflection
- Builder Pattern
- Serialization
- Editor Framework
开始大量出现。这里的目标已经不是让 CPU 工作得更快,而是让人类工作得更快。
很多工程师职业生涯早期都会寻找一种“正确架构”。仿佛只要选择了正确哲学。所有问题都会迎刃而解。
但大型工程的发展历史恰恰说明:这种架构并不存在。
- C with Classes 不是答案。
- Template Metaprogramming 不是答案。
- Everything is RAII 也不是答案。
它们都只是工具。
成熟工程最大的特征并不是统一。恰恰相反,成熟工程通常充满了矛盾。
最底层像 C,中间层像现代 C++,高层又像高级应用框架,不同思想在不同区域拥有不同的话语权。
因此,一个大型系统最终演化出的往往不是某种纯粹的设计哲学,而是一种分层妥协:底层服从硬件规律,中层服从软件工程规律,高层服从人类认知规律。
而这恰恰也是 C++ 最独特的地方:它允许这些完全不同的世界同时存在于同一个项目之中。
结语
C++ 的特殊之处不在于它提供了什么答案,而在于它允许程序员在几乎没有限制的情况下探索各种答案。因此 C++ 社区永远不会形成统一风格。
有人会走向 C with Classes,有人会走向 Template Metaprogramming,有人会坚持 Everything is RAII。
而真正重要的问题从来不是选择哪一个阵营,而是理解:每一种抽象都有成本,每一种简化都有代价。
工程设计的本质,不是追求某种哲学的纯粹性,而是在具体问题上选择最合适的工具,够用即可。
更多推荐

所有评论(0)