0%

条款01:视C++为语言联邦

《Effective C++》条款1原文所表达的意思是,C++不仅仅是C的增强版,它是由多个较小语言部分组成的“联邦”——有C的那部分,有面向对象编程的那部分,有模板编程的那部分,还有STL库,以至于每一部分的规则、最佳实践、陷阱都不尽相同

What’s mean?🤔

  • C++不是一个单一风格语言。
  • 各部分如:C风格、类/继承、模板、STL等,语法规则/最佳实践都有区别。
  • 工程实践时要清楚你用到的是哪一“部分”,思维不能混淆。

拿UE动画模块来看,“四大联邦”经常存在。

C风格部分

C风格部分的核心用途就是批量高效地处理数据,尤其是数学运算、结构体变换、数值拷贝,而不是复杂的对象管理。动画系统是性能密集型场景,每帧数据量巨大,所以用结构体、数组、裸指针等传统C语言手法来实现高效运算和内存布局。

在 C++ 的“C 风格联邦”中,核心关注点不是对象语义,而是数据布局、访问模式和执行效率

为什么不用OOP呢🤔

OOP适合资源管理、接口设计等,而底层动画计算/数据搬运更适合用C风格,无虚表、无GC、更贴近硬件。

C风格典型代码也是和数据操作相关。

1
2
3
4
5
6
7
UCLASS(BlueprintType)
class ANIMATIONDATACONTROLLER_API UAnimDataController : public UObject, public IAnimationDataController
{
bool SetTransformCurveKeys
......
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
bool UAnimDataController::SetTransformCurveKeys(
const FAnimationCurveIdentifier& CurveId,
const TArray<FTransform>& TransformValues,
const TArray<float>& TimeKeys,
bool bShouldTransact /*= true*/)
{
for (int32 KeyIndex = 0; KeyIndex < TransformValues.Num(); ++KeyIndex)
{
const FTransform& Value = TransformValues[KeyIndex];
const float& Time = TimeKeys[KeyIndex];

const FVector Translation = Value.GetLocation();
const FVector Rotation = Value.GetRotation().Euler();
const FVector Scale = Value.GetScale3D();

// ...把每一维的float写到ChannelKeys数组
}
}

在这个代码里,极大量用结构体、用数组去处理数据,并且数据是批量并行处理,没有复杂的对象间关系,堆内数组按键数分配,生命周期完全由局部逻辑管控。

在这个代码里是非常典型的批量结构体处理,逐项应用变换,计算过程不涉及对象层的复杂性,只处理数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct FKeys
{
FKeys(int32 NumKeys)
{
for (int32 ChannelIndex = 0;
ChannelIndex < 3;
++ChannelIndex)
{
ChannelKeys[ChannelIndex].SetNum(NumKeys);
}
}

TArray<FRichCurveKey> ChannelKeys[3];
};

FKeys这个临时 struct,也是只装数据,没有虚函数或继承。

ChannelKeys[ChannelIndex][KeyIndex] = FRichCurveKey(...),直接用下标,堆内连续数据,更容易优化。

上述代码就是在写**“C风格”联邦的动画控制代码**:结构体加数组加for循环,不需要面向对象。只需要关心 Transform 的各个 float 数据,每一个 channel 都是 float 数组,无需 OOP,也不玩模板泛型。这样的代码,就是为了性能、内存管理、更贴近硬件优化。

比如传入 FAnimationCurveIdentifier、通过 Model->FindMutableTransformCurveById() 获取曲线,就是在用对象、接口来找资源、做封装。这是“面向对象”部分。

再比如 SetCurveKeys 调用,是模块接口(OOP层)和数据层(C风格)之间的桥梁。

为什么这么设计?

UE 的动画系统这里做的是“曲线批量设置”,都用结构体加数字数组处理,性能最优、代码干净。

曲线驱动(Curve Driven)动画核心就是把很多 float 组广泛写入,通过底层数据优化达到高效动画效果。

TArray<FTransform>是业务层的面向对象设计(动画资源的接口),而落到“批量变换”这一点,全部按 C 派习惯处理。

C++风格部分

C++(面向对象)联邦:负责“资源、生命周期与系统边界”。如果说动画系统中的 C 风格联邦负责“怎么快”,
那么 C++ 面向对象联邦负责的就是“谁在用、什么时候用、用到哪一步”。

在 UE 动画模块中,这一部分代码几乎不参与具体的动画数值计算,而是承担以下职责:

  • 动画资源的生命周期管理
  • 编辑器 / 运行时的系统边界隔离
  • 模块接口与职责划分
  • Undo / Redo、事务、依赖追踪
  • 多系统如动画、骨骼、曲线、Sequencer的协作

这些问题,用 C 风格是完全不合适的,必须使用 C++ 的对象模型。

关于UE 动画模块中的 OOP 使用边界,我们先看一个非常典型的入口类:

1
2
3
4
5
6
7
8
UCLASS(BlueprintType)
class ANIMATIONDATACONTROLLER_API UAnimDataController
: public UObject
, public IAnimationDataController
{
...
};

这一层的关键点不在于“它能干什么计算”,而在于:它是 UObject,它实现了 接口(Interface),它参与 反射、GC、事务系统。这已经清楚地表明它属于 C++ 面向对象联邦,而不是 C 风格联邦。

为什么动画系统必须有这一层 OOP?

首先,动画数据是“资源”,不是临时数据,动画曲线、关键帧、骨骼轨道这些需要需要被编辑器引用需要被保存 / 回滚,需要被多个系统共享,生命周期可能跨越数小时甚至整个项目。

这类数据的特征是:生命周期长、引用复杂、所有权必须清晰。这是典型的 OOP + 引擎对象系统 擅长的领域。

**OOP 在动画模块中“刻意不下沉”**一个非常重要、但容易被忽略的设计点是:UE 动画系统中的 OOP,刻意不深入到数据处理内部。

1
2
3
4
5
6
7
for (int32 KeyIndex = 0; KeyIndex < TransformValues.Num(); ++KeyIndex)
{
const FTransform& Value = TransformValues[KeyIndex];
const float& Time = TimeKeys[KeyIndex];
...
}

在这里面没有没有虚函数,没有多态,没有“Key 对象”,没有“Channel 类”。这些都不是遗漏,而是刻意避免。

原因很简单,因为每一帧都要跑,每个动画都有,Key 数量巨大,虚函数、对象拆分、间接访问都会被放大成性能成本。

模板部分

Template(泛型)联邦:用于“编译期规则”,而不是运行期行为

如果说,C 风格联邦解决的是 “怎么跑得快”,C++ 面向对象联邦解决的是 “谁负责、谁持有、谁调用”,那么 Template 联邦在 UE 动画模块中解决的,是第三类问题——“规则是否成立”、“类型是否允许”、“逻辑是否在编译期就能被限制住”。它关注的不是数据本身,也不是对象关系,而是类型系统与编译期约束

在《Effective C++》的视角下,Template并不是“更高级的多态工具”,而是一个完全不同的问题域

我们先明确一件事情:模板的核心价值,从来不在于“能不能抽象”,而在于能不能在编译期解决问题。

模板本质上解决的是什么问题?

其实从语言层面来看,模板做的事情只有三类:

  1. 类型参数化

    这个逻辑对一组确定的类型成立

  2. 编译器分支

    当类型满足某些条件时,生成不同的代码

  3. 编译期约束

    不满足条件的类型,根本不允许通过编译

这里有一个关键词——“编译期”。也就是说,模板并不关心对象的生命周期,运行期的组合关系,多态行为的动态变化。这些是OOP的问题域,而不是模板的。

在 UE 动画系统中,模板极少用于抽象算法表达,运行期多态,实现“泛型动画节点”。

在直觉上,我们可能会觉得“AnimNode 很适合模板”,但实际上并不合适。很多人看到AnimGraph,会自然想到——这么多 AnimNode,不就是同一套逻辑作用在不同类型上吗?那为什么不用 template 呢?

这个直觉从语法层面是合理的,但从引擎系统层面是错误的

AnimNode 的核心需求是节点在运行期动态连接,节点类型由 蓝图 / 编辑器决定,节点实例由 反射系统构建,每一帧通过统一接口执行。有几个关键词非常重要:运行期、动态、反射、统一入口

1
2
3
4
5
struct FAnimNode_Base
{
virtual void Evaluate_AnyThread(FPoseContext& Output) = 0;
};

从UE的基础定义来看,这里的设计选择非常明确。

在 AnimGraph 中,“用哪个动画节点”并不是在 C++ 编译时确定的,而是在运行期由动画蓝图资产决定的。具体来说、动画蓝图里放了哪些节点、节点之间如何连接,每个节点当前是否启用。这些信息都来自编辑器中的蓝图配置、运行时加载的动画资产、甚至运行时的状态切换。

动画系统需要一种机制,让不同类型的节点在运行期都能通过同一个接口被调用

这正是运行期多态要解决的问题。

如果试图用模板改写 AnimNode,会立刻遇到很多麻烦。模板无法表达“运行期节点图”。AnimGraph 是运行期可配置的节点网络,而模板要求类型在编译期完全确定,这两者在本质上是冲突的。

那我们看下面的代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/** Helper function to convert the input for GetActions to a list that can be used for delegates */
template <typename T>
static TArray<TWeakObjectPtr<T>> GetTypedWeakObjectPtrs(const TArray<UObject*>& InObjects)
{
check(InObjects.Num() > 0);

TArray<TWeakObjectPtr<T>> TypedObjects;
for (auto ObjIt = InObjects.CreateConstIterator(); ObjIt; ++ObjIt)
{
TypedObjects.Add(CastChecked<T>(*ObjIt));
}

return TypedObjects;
}

先解释一下作用:把一组 UObject*,在“已经确信类型正确”的前提下,转换成指定类型的 TWeakObjectPtr 数组。

这个函数解决的是一个非常具体、非常局部的问题:输入类型是 UObject*,实际语义上“它们都是某种具体类型 T”,希望得到一个类型安全的结果列表,并且避免写大量重复代码。

为什么这里“适合用模板”?

关键在于:这个函数的“变化点”只有一个——类型 T。类型在调用点是确定的,当我们调用:

1
auto TypedNodes = GetTypedWeakObjectPtrs<UAnimGraphNode_Base>(SelectedObjects);

这里的 T编译期就已经完全确定,不依赖运行期逻辑以及不由用户配置或资产决定。这正是模板最擅长使用的场景。

这个模板不参与运行期行为,不保存状态,不定义对象生命周期,不参与动画更新,不进入任何热路径,仅仅是一个**“类型安全的转换工具”**。

💡UE 中的模板,通常用于“让已经确定的事情更安全”,而不是“推迟决定”。

STL部分

说真的,UE在有意避免STL。

在 C++ 的“语言联邦”中,STL是一个非常特殊的存在。如果说C 风格联邦关注的是数据与性能;C++ 面向对象联邦关注的是系统与生命周期;Template 联邦关注的是编译期规则;STL 联邦关注的核心问题就是如何用一组标准化的容器和算法,解决“通用程序”的数据组织问题。

STL 的目标从一开始就非常明确——标准、通用、可移植、对大多数程序足够友好。

而正是这一点,让它和 UE 这样的游戏引擎,在设计目标上逐渐分道扬镳。

STL 到底解决了什么问题?

std::vectorstd::setstd::map 为代表的 STL 容器,解决的是这样一类问题:数据规模不确定,使用场景多样,性能需求“合理即可”,行为必须符合标准语义。

1
2
3
std::vector<int> Values;
Values.push_back(10);
Values.push_back(20);

我们不需要关心内存怎么分配,是否跨平台,在哪个编译器下表现如何。STL 的设计哲学是**把复杂性隐藏在标准之后,让使用者“放心用”。**这在应用程序、工具、库代码中非常成功。

但UE 面临的问题,和 STL 的设计前提不同。UE 并不是“普通应用程序”,而是一个跨平台引擎,编辑器 + 运行时共存并且强工具链依赖。UE 面临的不是这样写会不会方便,而是这样写是否可预测、可追踪、可被引擎理解。

最关键的冲突是内存管理。对于STL来说内存是实现细节,我们无法确定扩容策略是否一致,不同平台 / 编译器下行为是否完全相同。即使 C++ 标准允许自定义 allocator,在一个数百万行代码、跨多个 STL 实现的引擎里,这仍然是不可控的。

对于UE来说,内存必须是**“可控系统”**

1
TArray<FTransform> Transforms;

这意味着所有内存来自 UE 的内存系统,可以用来统计、分析、调优,可以在编辑器中可视化,可以针对动画、渲染做专项优化。在UE层面。内存不是容器的私事,而是整个引擎的核心资源

从性能模型来看,STL 是**“通用最优”,UE 要“场景最优”**。

STL 容器的优化目标是,在不知道你要拿它干什么的前提下,尽量快。

UE 的容器设计目标是,我非常清楚你是做动画、渲染、物理,我为此优化。

💡STL 是 C++ 的一个子语言,而 UE 并不总是在这个子语言中工作。

小结

其实从上面我们也可以看出,UE 并不是**“用 C++”,而是“选择性地使用 C++”**。

回到《Effective C++》条款 01,“把 C++ 视为一个语言联邦”,在 UE 的动画模块中并不是一句抽象的设计哲学,而是一套非常具体、可感知的工程实践

C 风格联邦用于高频、批量的数据处理,追求确定的内存布局和极致性能。

C++ 面向对象联邦用于系统边界、资源生命周期与模块协作。

Template 联邦用于编译期规则、类型约束与错误前置。

STL 联邦则被刻意限制在引擎核心之外,只在通用工具和外围代码中使用。

或许真正重要的不是**“会不会用 C++ 的所有特性”**,而是你是否清楚:正在解决的这个问题,应该站在哪一个语言联邦里思考。

这正是《Effective C++》条款 01 在 UE 动画系统中的真实含义。