为什么 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);
};

你可以选择:手动管理生命周期RAIIGarbage CollectionArena 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。

而真正重要的问题从来不是选择哪一个阵营,而是理解:每一种抽象都有成本,每一种简化都有代价。

工程设计的本质,不是追求某种哲学的纯粹性,而是在具体问题上选择最合适的工具,够用即可。

更多推荐