CppCon 2023 学习: Can data-oriented-design be improved?
核心思想:数据是中心,程序是数据的变换核心目标:优化数据访问、提高变换效率区别于 OOP:不强调对象封装和继承,而强调数据流和变换数学抽象:程序可被视作函数组合DoutTn∘⋯∘T1DinDoutTn∘⋯∘T1DinData∗outputFData∗inputData∗outputFData∗input意思是:程序可以被抽象为对数据的变换DatainputDatainput:输入数据Da
DoD(Data-Oriented Design,数据导向设计)这一概念,并给出理解
1. 基本定义
DoD,即 数据导向设计,是一种程序设计范式,其核心关注点不是代码的对象抽象或类的封装,而是 数据本身及其在程序中的变换。
根据维基百科的定义:
“As a design paradigm, data-oriented-design focuses on optimal transformations of data and focuses on modelling programs as transforms.”
成就是:
作为一种设计范式,数据导向设计关注 数据的最优变换,并将程序建模为 对数据的转换。
这里有几个关键词需要理解:
- 最优变换(optimal transformations of data)
DoD 的关注点是如何高效地操作数据,而不是如何组织类或对象。例如,你可能会考虑如何一次性处理一批数据(批量操作),而不是针对单个对象频繁调用方法。 - 程序建模为数据转换(programs as transforms)
在 DoD 中,程序可以被看作是一系列从输入数据到输出数据的函数或变换:
f:Input Data→Output Data f: \text{Input Data} \rightarrow \text{Output Data} f:Input Data→Output Data
举例来说,如果有一组实体的位置和速度,程序可以被建模为一个变换函数:
update_positions(positions,velocities,Δt)→new positions \text{update\_positions}(\text{positions}, \text{velocities}, \Delta t) \rightarrow \text{new positions} update_positions(positions,velocities,Δt)→new positions
这里关注的不是实体对象的封装或方法,而是 数据本身及其转换逻辑。
2. 与传统 OOP 的区别
- OOP(面向对象编程)
关注对象和行为:类是中心,方法在对象上操作数据。
核心思路:封装、继承、多态。 - DoD(数据导向设计)
关注数据和变换:数据是中心,程序是对数据的函数式处理。
核心思路:优化数据流、减少缓存错失、批量处理数据。
注意:很多人误以为 DoD 只是为了优化缓存行或者结构体布局,但这只是 DoD 的一种实际优化手段,并非核心理念。
3. 数学化表述
可以把 DoD 的核心思想形式化为函数式变换。设数据集合为 DDD,程序中的操作为一系列变换 TiT_iTi,则整个程序可以表示为:
Dout=Tn∘Tn−1∘⋯∘T1(Din) D_{\text{out}} = T_n \circ T_{n-1} \circ \dots \circ T_1 (D_{\text{in}}) Dout=Tn∘Tn−1∘⋯∘T1(Din)
其中:
- DinD_{\text{in}}Din:输入数据集合
- DoutD_{\text{out}}Dout:输出数据集合
- TiT_iTi:单个数据变换函数
- ∘\circ∘:函数组合(composition),表示将前一个变换的输出作为下一个变换的输入
这种方式让程序可以被清晰地分析和优化,例如: - 批量处理可以减少内存访问次数
- 数据排列可以提高缓存命中率
- 并行处理可以天然映射到 SIMD 或 GPU 上
4. 直观理解举例
假设我们有一个游戏中的实体(Entity),每个实体有位置 xxx 和速度 vvv。在 OOP 中,你可能写成:
for (auto& entity : entities) {
entity.position += entity.velocity * dt;
}
在 DoD 中,你可能把数据拆成数组:
positions=[x1,x2,x3,…,xn] \text{positions} = [x_1, x_2, x_3, \dots, x_n] positions=[x1,x2,x3,…,xn]
velocities=[v1,v2,v3,…,vn] \text{velocities} = [v_1, v_2, v_3, \dots, v_n] velocities=[v1,v2,v3,…,vn]
然后统一做批量更新:
positions[i]=positions[i]+velocities[i]⋅Δt \text{positions}[i] = \text{positions}[i] + \text{velocities}[i] \cdot \Delta t positions[i]=positions[i]+velocities[i]⋅Δt
这样做的好处:
- 数据连续存储,提升缓存效率
- 容易向 SIMD/GPU 映射,做向量化计算
- 逻辑简单清晰:函数只关注数据转换
5. 总结
- 核心思想:数据是中心,程序是数据的变换
- 核心目标:优化数据访问、提高变换效率
- 区别于 OOP:不强调对象封装和继承,而强调数据流和变换
- 数学抽象:程序可被视作函数组合
Dout=Tn∘⋯∘T1(Din) D_{\text{out}} = T_n \circ \dots \circ T_1 (D_{\text{in}}) Dout=Tn∘⋯∘T1(Din)
1. DoD 的极简定义
DoD 的核心可以极简地表示为一个公式:
Data∗output=F(Data∗input) \text{Data}*{\text{output}} = F(\text{Data}*{\text{input}}) Data∗output=F(Data∗input)
意思是:程序可以被抽象为 对数据的变换,其中:
- Datainput\text{Data}_{\text{input}}Datainput:输入数据
- Dataoutput\text{Data}_{\text{output}}Dataoutput:输出数据
- FFF:对数据的具体变换(Transformation)
用图示理解:
Previous transformation → Input Data → Specific Transformation → Output Data → Next transformation
这里强调 每个程序块都是对数据的特定变换,而不是对对象或类的操作。
2. DoD 在实际代码中的使用方式
实际中,DoD 的实现往往具有以下特征:
- 平台相关(Platform specific code)
- 过程式/命令式(Procedural/imperative code)
- 问题/数据集特定(Problem/dataset specific code)
- 手动优化缓存行和结构体布局(Hand-optimize cache lines and struct layout)
换句话说,DoD 的实现往往与硬件和特定问题高度耦合。
3. 改进思路(第一轮尝试)
- 可以尝试用 AI 工具(如 ChatGPT)帮助优化
- 但是仅靠工具无法解决根本问题
- 核心仍然是:
Data∗output=F(Data∗input) \text{Data}*{\text{output}} = F(\text{Data}*{\text{input}}) Data∗output=F(Data∗input) - 核心关注应放在 数据本身 上
4. 相反哲学(Functional Programming 思路)
与传统 DoD 对应的“相反哲学”是:
| 数据导向代码 (DoD) | 相反哲学(函数式/声明式) |
|---|---|
| 平台相关 | 跨平台(Cross platform) |
| 过程式/命令式 | 声明式(Declarative) |
| 数据集/问题特定 | 泛型代码(Generic code) |
| 手动优化缓存行和结构 | 由编译器处理缓存/结构布局 |
| 这与 函数式编程(FP) 的思想非常接近: |
- 程序通过 函数链(function chaining) 构建
- 可以把多个变换 F,G,HF, G, HF,G,H 链接起来:
Data∗output=H(G(F(Data∗input))) \text{Data}*{\text{output}} = H(G(F(\text{Data}*{\text{input}}))) Data∗output=H(G(F(Data∗input))) - 核心仍是数据变换,只是更抽象、更泛型、更跨平台
5. DoD 与函数式编程的关系
- DoD 和 FP 可能在实现上有强差异(如性能优化、缓存利用)
- 但两者在 程序建模为数据变换 的理念上有强相似性
- 相比 OOP(面向对象编程),DoD 与 FP 的相似度更高
数学上可以用组合函数表示整个程序:
Data∗out=Tn∘T∗n−1∘⋯∘T1(Datain) \text{Data}*{\text{out}} = T_n \circ T*{n-1} \circ \dots \circ T_1(\text{Data}_{\text{in}}) Data∗out=Tn∘T∗n−1∘⋯∘T1(Datain) - TiT_iTi:单个数据变换函数
- ∘\circ∘:函数组合(composition)
6. C++ 中可用的函数式编程特性
在 C++ 中可以应用 FP 思想:
运行时(runtime):
- Lambda 表达式
- STL 算法(sort, transform 等)
- Ranges
- 函数指针
编译时(compile-time): - 类型作为数据(Metaprogramming)
- 模板和 constexpr 函数
7. 引入“变换导向设计(Transformation-Oriented Design, TOD)”
概念定义:
- TOD = 编译器级别的函数式编程
- 核心关注点仍然是 变换(Transformation),即函数
- 可以理解为 DoD + FP 的进化版:强调 编译器优化 + 数据变换抽象
公式上依然是:
Data∗output=F(Data∗input) \text{Data}*{\text{output}} = F(\text{Data}*{\text{input}}) Data∗output=F(Data∗input)
只是 FFF 可以在 编译时展开、组合和优化,实现更高效的程序。
8. 小结
- DoD 核心:程序是数据变换
Data∗out=F(Data∗in) \text{Data}*{\text{out}} = F(\text{Data}*{\text{in}}) Data∗out=F(Data∗in) - 实际实现:平台/问题特定、命令式、手动优化缓存
- 相反哲学:函数式/声明式、泛型、由编译器处理底层优化
- 函数式编程:通过函数链构建程序
Data∗out=H(G(F(Data∗in))) \text{Data}*{\text{out}} = H(G(F(\text{Data}*{\text{in}}))) Data∗out=H(G(F(Data∗in))) - TOD:编译器级别的函数式 + 数据导向设计,强调 变换的抽象和优化
1. 传统 OOP 方法
设计特点:
- 定义一个 基类 Image
- 含有虚函数
ReadFile()和WriteFile() - 保存图像数据
- 含有虚函数
- 定义派生类(Derived classes)如
PNGFile,GIFFile,JPEGFile- 特化加载方法和识别方法
- 可保存文件特定数据
示意:
Image File
virtual void ReadFile()
virtual void WriteFile()
- Image data
PNG File
void ReadFile()
void WriteFile()
GIF File
void ReadFile()
void WriteFile()
JPEG File
void ReadFile()
void WriteFile()
OOP 的缺点:
- 虚函数和 vtable 调用不可避免(运行时开销)
- 灵活性低,设计被锁定(数据和函数绑定)
- 扩展新类型需要修改原有类层次结构
2. TOD(变换导向设计)方法
核心思想:
- 对象只包含 函数(transformations)
- 数据和函数 不天然绑定
- 无基类
- 用 类型列表(typelist) 替代继承结构
示意:
FilePNG
const Image& ReadFile()
void WriteFile(const Image&)
FileGIF
const Image& ReadFile()
void WriteFile(const Image&)
FileJPEG
const Image& ReadFile()
void WriteFile(const Image&)
using ImageFormatDriver_t = typelist<FilePNG, FileGIF, FileJPEG>;
特点:
- 每个“变换包(transform package)”是独立的类型
- 不需要虚函数
- 编译期可组合、检查、优化
3. TOD 与 OOP 对比表
| 概念 | OOP | TOD |
|---|---|---|
| 统一原则 | Base class | Type list of transform packages |
| 派生类 | Derived classes | Specific classes |
| 数据是否绑定 | 是 | 否(大多数情况) |
| 虚函数 | 必需 | 无 |
| 签名方法(Signature method) | 严格 | 自由 |
4. 变换包(Transform Package)定义
变换包的内容:
- 静态函数/变换(Static transformations)
- 类型定义(using definitions)
- 枚举(Enums)
- 静态数据(Static data)
性质: - 仅在编译期可操作
- 不可实例化或销毁
- 可自由选择是否使用
公式化表述 TOD 核心:
Output Data=F(Input Data) \text{Output Data} = F(\text{Input Data}) Output Data=F(Input Data)
其中 FFF 是 静态变换函数,在编译期可组合成整个处理流程。
5. TOD 设计影响
- 抽象化:从具体变换解耦
- 最小化文档:每个变换包独立清晰
- 模块化代码:方便重用
代码结构:
Library
- Transformation definitions
- Utility functions
User-side code
- Package implementation files
- Specific transformations used
- Execution path definition
6. TOD 编程要点
- 用 编译期函数组合 替代 OOP 的虚函数调度
- 替换硬编码的 switch/case 分支
- 只调用存在的函数
- 尽量 不依赖反射
公式表示:
Program=Tn∘Tn−1∘⋯∘T1(Datainput) \text{Program} = T_n \circ T_{n-1} \circ \dots \circ T_1(\text{Data}_{\text{input}}) Program=Tn∘Tn−1∘⋯∘T1(Datainput)
- TiT_iTi:单个变换函数
- ∘\circ∘:函数组合(Compile-time composition)
7. 总结
- OOP:数据 + 方法绑定,虚函数调度,运行时灵活但性能受限
- TOD:函数式/变换导向,数据与函数解耦,编译期组合,性能可优化
- TOD 的核心:
- 静态变换包(Transform packages)
- 编译期函数式编程
- 利用现代 C++ 特性(模板、constexpr、类型列表等)
TOD 示例代码及相关内容做详细解析,并结合数学公式或形式化表达帮助理解。
1. 示例 1:类型列表与类型检查
代码片段使用了 编译期类型列表(TypeList),用来判断某个类型是否存在于类型列表中。
namespace Local { // 局部命名空间,防止与外部命名冲突,常用于模板元编程
// 递归模板函数,用于在编译期判断 Type 是否存在于类型列表 TList 中
// Index: 当前检查的索引位置
// Type: 需要检查的类型
// TList: 类型列表
template<size_t Index, typename Type, typename TList>
constexpr bool TypeInListImpl() {
// 如果当前索引对应的类型与 Type 相同,返回 true
if constexpr (std::is_same_v<Type, TypeAt_t<Index, TList>>)
return true;
// 如果已经检查到列表开头还没找到,返回 false
else if constexpr (Index == 0)
return false;
// 否则递归检查前一个索引
else
return Local::TypeInListImpl<Index - 1, Type, TList>();
}
} // namespace Local
// 对外接口函数:判断 Type 是否在 TList 中
// Type: 需要检查的类型
// TList: 类型列表
template<typename Type, typename TList>
constexpr bool TypeInList() {
// 调用内部实现,从类型列表最后一个索引开始检查
// Length_v<TList> 是 TList 的长度
return Local::TypeInListImpl<Length_v<TList> - 1, Type, TList>();
}
理解:
TypeList是一个编译期类型集合TypeInList<Type, TList>()判断Type是否在TList中- 利用 递归模板和 if constexpr 在编译期实现
- 形式化表述:
TypeInList(T,L)={true,if T=L[i] for some ifalse,otherwise \text{TypeInList}(T, L) = \begin{cases} \text{true}, & \text{if } T = L[i] \text{ for some i} \\ \text{false}, & \text{otherwise} \end{cases} TypeInList(T,L)={true,false,if T=L[i] for some iotherwise
2. 示例 2:静态函数调用(Transform Call)
定义变换调用宏:
// 定义一个宏,用于生成 "TransformCall" 模板函数
// Function: 想要调用的函数名,例如 Mess、ReadFile 等
#define TRANSFORM_CALL(Function) \
\
template< typename Type, /* 当前要调用函数的类型 */ \
typename TList, /* 类型列表(TypeList) */ \
typename TReturn, /* 函数返回值类型 */ \
typename... Args> /* 可变模板参数,表示函数参数类型 */ \
TReturn TransformCall##Function(Args&& ...args) { \
\
// 获取类型列表的第一个类型,作为默认类型
using Default = TypeAt_t<0, TList>; \
\
// 如果 Type 不在类型列表中,则调用默认类型的函数
// TypeInList<Type, TList>() 在编译期判断类型是否存在于类型列表
if constexpr (!TypeInList<Type, TList>()) \
return Default::Function(args...); \
\
// 如果 Type 存在,并且 Type 具有该函数,则调用 Type 的函数
if constexpr (requires() { Type::Function; }) \
return Type::Function(args...); \
\
// 否则调用默认类型的函数(Type 存在但没有该函数)
else \
return Default::Function(args...); \
}
理解:
TransformCallFunction会在 编译期决定调用哪个类型的函数- 如果
Type不在类型列表中 → 调用默认类型(通常是第一个类型) - 如果
Type存在但没有该函数 → 调用默认类型的函数 - 如果
Type存在并有该函数 → 调用Type的函数
- 形式化表示:
TransformCallFunction(T,L)={Default::Function(args...),T∉LT::Function(args...),T∈L 且存在该函数Default::Function(args...),otherwise \text{TransformCallFunction}(T, L) = \begin{cases} \text{Default::Function(args...)}, & T \notin L \\ T::Function(args...), & T \in L \text{ 且存在该函数} \\ \text{Default::Function(args...)}, & \text{otherwise} \end{cases} TransformCallFunction(T,L)=⎩ ⎨ ⎧Default::Function(args...),T::Function(args...),Default::Function(args...),T∈/LT∈L 且存在该函数otherwise
调用示例:
TransformCallMess<A, TransformObjectList, void>(); // 输出 "...default"
TransformCallMess<B, TransformObjectList, void>(1); // 输出 "...b"
TransformCallMess<C, TransformObjectList, void>(); // 输出 "...default"
- A 类在类型列表中,但没有
Mess函数 → 调用默认 - B 类有
Mess(int)→ 调用 B 的实现 - C 类不在类型列表中 → 调用默认
3. 示例 2:类型列表与签名查找
// 定义一个类型列表 ImageFormat,包含常用图像格式
using ImageFormat = TypeList<BMP, JPEG, PNG, TIFF>;
namespace Local {
// 递归模板函数,用于在编译期查找某个类型在类型列表中的索引位置
// Index: 当前检查的索引
// TList: 类型列表
template<size_t Index, typename TList>
size_t SignatureImpl() {
// 如果类型列表当前索引对应的类型存在(非空或有效)
if (TypeAt_t<Index, TList>)
return Index; // 返回该索引作为签名值
// 如果已经检查到列表开头(Index == 0)还没有找到
if constexpr (Index == 0)
return -1; // 返回 -1 表示类型未找到
// 否则递归检查前一个索引
else
return Local::SignatureImpl<Index - 1, TList>();
}
}
// 对外接口函数,用于获取类型 Type 在类型列表 TList 中的索引位置
// Type: 需要查找的类型
// TList: 类型列表
template<typename Type, typename TList>
constexpr size_t Signature() {
// 从类型列表最后一个索引开始查找
// Length_v<TList> 表示类型列表长度
return Local::SignatureImpl<Length_v<TList> - 1, Type, TList>();
}
理解:
- 编译期查找某个类型在
TypeList中的位置 - 若类型不存在 → 返回
-1 - 可用于 编译期选择合适的变换或函数实现
- 形式化表述:
Signature(T,L)={i,if T=L[i]−1,if T∉L \text{Signature}(T, L) = \begin{cases} i, & \text{if } T = L[i] \\ -1, & \text{if } T \notin L \end{cases} Signature(T,L)={i,−1,if T=L[i]if T∈/L
4. TOD 的关键思想总结
- 类型列表(TypeList) 替代虚函数和 switch/case
- 编译期选择函数,无需运行时虚表
- 函数调用安全:仅调用存在的函数,缺省调用默认实现
- 函数和数据解耦:对象本身无需持有数据
- 优化在编译期完成:减少运行时开销
5. 未来发展方向
- 利用 C++20/23 特性:
tag_invokemetaclassesstatic reflection- 数据作为类型(Data as types)
- 或通过
std::type_sequence等标准工具实现类似效果
6. 核心公式总结
- 数据变换公式:
Data∗out=F(Data∗in) \text{Data}*{\text{out}} = F(\text{Data}*{\text{in}}) Data∗out=F(Data∗in) - TransformCall 函数逻辑:
TransformCallFunction(T,L)={Default::Function(args...),T∉LT::Function(args...),T∈L 且存在函数Default::Function(args...),otherwise \text{TransformCallFunction}(T, L) = \begin{cases} \text{Default::Function(args...)}, & T \notin L \\ T::Function(args...), & T \in L \text{ 且存在函数} \\ \text{Default::Function(args...)}, & \text{otherwise} \end{cases} TransformCallFunction(T,L)=⎩ ⎨ ⎧Default::Function(args...),T::Function(args...),Default::Function(args...),T∈/LT∈L 且存在函数otherwise - 类型签名查找公式:
Signature(T,L)={i,T=L[i]−1,T∉L \text{Signature}(T, L) = \begin{cases} i, & T = L[i] \\ -1, & T \notin L \end{cases} Signature(T,L)={i,−1,T=L[i]T∈/L
7. 总结要点
- TOD 与传统 DoD 或 OOP 不同:核心是 编译期数据与函数变换组合
- 无需运行时虚函数或 switch/case
- 模板和类型列表 是主要工具
- 可利用现代 C++ 功能实现 高性能、类型安全、可扩展的变换调用
- TOD 可以与其他范式共存,不必全模板化
更多推荐

所有评论(0)