------ 关于语言设计权衡的讨论
探索C语言动画引擎迁移C++可行性
[一、Panyim 的任务系统架构](#一、Panyim 的任务系统架构)
二、为什么需要自定义虚表
[三、从 C 迁移到 C++](#三、从 C 迁移到 C++)
[四、现代 C++](#四、现代 C++)
[五、 C 是 C++ 的子集为误解](#五、 C 是 C++ 的子集为误解)
附录
探索将一个现有的 C 语言实现的编程动画引擎迁移到 C++ 的可行性
发现 C++ 的虚函数并不能解决实际存在的问题
一、Panyim 的任务系统架构
现有一套基于 Task(任务) 的动画描述系统:
单个根任务 → 控制整个动画流程
整个动画由一个单一的根任务驱动,这个任务内部又由复合任务(Compound Tasks)组成
1.2 任务类型
实现了几种基本任务类型:
| 任务 | 作用 | 示例 |
|---|---|---|
| Wait | 等待指定时间 | wait_ms(750) |
| Write | 向单元格写入符号 | 写入文字或图片 |
| Sequence | 顺序执行多个任务 | 播放 intro → 写符号1 → 移动 → 写符号2... |
| Group | 同时执行多个任务 | Windows 加载动画(三个方块同时移动) |
1.3 Group 任务的实现
Windows 加载动画的实现方式:
- 定义一个
shuffle函数,产生一次方块移动 - 三个 shuffle 组成一个 Group,三个方块同时移动
- 三个 Group 组成 Sequence,形成完整的加载动画
c
// 三个移动同时发生
TaskGroup {
Move(square1, target1),
Move(square2, target2),
Move(square3, target3)
}
二、为什么需要自定义虚表
2.1 问题:热重载与 DLL
Panyim 的插件系统基于 DLL(动态链接库):
- 自定义动画以 DLL 形式存在
- 插件状态在 DLL 重载之间保持存活
- 问题:
函数指针在重载后会失效
2.2 C 的解决方案:索引替代指针
c
struct Task {
int tag; // 类型标签
void* data; // 指向具体任务数据
};
// 虚拟表(函数指针数组)
typedef void (*UpdateFn)(Task*, Environment*);
// 通过索引访问函数,而不是直接持有函数指针
table[task->tag].update(task, env);
- 函数指针数组在每次插件加载时重新构建
- 任务持有的是索引,索引永远不会失效
- 索引指向的是重载后重新构建的函数指针
这本质上就是一个虚拟表:
- 可以指定在哪个
Arena 中分配 - 掌控布局、查找方式
- 完全
暴露,无隐藏
2.3 为什么 C++ 的虚函数不能解决问题
尝试用 C++ 重新实现 Task 系统,使用标准的抽象类:
cpp
class Task {
public:
virtual bool update(Environment& env) = 0; // 纯虚函数
};
结果:用 C++ 重写后,热重载时同样崩溃。
原因:
C++ 编译器的虚表布局是**标准未定义**的- 无法访问/修改虚表指针
虚表本身在 DLL 重载后会失效,且无法像手动维护的表那样重建
自定义虚表系统本质上比 C++ 的虚函数更适合这个场景,解决了 C++ 虚函数无法解决的问题
三、从 C 迁移到 C++
3.1 名称修饰(Name Mangling)
C++ 会对函数名进行修饰(mangle),导致符号名与 C 不兼容:
C 函数: hello
C++ 函数: _Z5hellov
方案 :extern "C"
cpp
extern "C" {
// 包裹不想被修饰的函数
void say_hello();
}
3.2 编译错误与隐式转换
尝试将 C 代码编译为 C++ 时遇到的问题:
问题1 :void* 与具体指针类型的隐式转换
c
// C 中:任意指针可自动转为 void*
// C++ 中:需要显式转换
char* ptr = (char*)arena_alloc(arena, size);
问题2 :decltype 语法
cpp
// C++ 需要正确的 decltype 用法
auto type = decltype(arena.base)();
问题3:初始化顺序
cpp
// C++ 构造函数的初始化列表顺序与变量声明顺序一致
// 而不是与初始化列表中的书写顺序一致
Wait(float duration) : duration(duration), cursor(0.0f), started(false) {}
// duration 必须在 cursor 之前声明
3.3 预处理器兼容性
编写兼容性宏,使代码同时兼容 C 和 C++:
c
#ifdef __cplusplus
#define DECLTYPE(x) decltype(x)
#else
#define DECLTYPE(x) typeof(x)
#endif
四、现代 C++
4.1 模板与变参模板
cpp
template<typename... Ts>
class Sequence : public Task {
private:
std::vector<Task*> tasks;
size_t current;
public:
Sequence(Ts... args) : tasks({args...}), current(0) {}
bool update(Environment& env) override {
if (current >= tasks.size()) return true;
bool done = tasks[current]->update(env);
if (done) current++;
return current >= tasks.size();
}
};
示例:
cpp
new Sequence(
MoveV2(position, vec2(200, 200), 0.5f),
MoveV2(position, vec2(0, 0), 0.5f)
);
4.2 Placement New 与 Arena
当需要在 Arena 中构造 C++ 对象时:
cpp
// 1. 先用 Arena 分配原始内存
void* memory = arena_alloc(arena, sizeof(MyClass));
// 2. 用 Placement New 在指定内存构造对象
MyClass* obj = new (memory) MyClass(args...);
作用:
- 不调用 malloc,让内存管理统一到 Arena
- 显式告诉编译器对象生命周期的起始点
- 支持编译器进行正确的优化
4.3 unique_ptr 与内存泄漏
尝试使用 unique_ptr 管理 Task 的生命周期,但遇到了模板参数推导问题:
cpp
// 失败:模板无法推导
auto task = std::make_unique(MoveV2(pos, vec2(100, 100), 1.0f));
// 必须显式指定类型
auto task = std::make_unique<MoveV2>(pos, vec2(100, 100), 1.0f);
最终选择直接使用裸指针 + 手动删除,因为"不需要记住编译器想要什么"。
4.4 X 宏(X Macros)
一种 C 语言的编译时元编程技术,用于解决"多处需要同步更新"的问题
c
// 定义列表
#define FRUITS(X) \
X(apple) \
X(banana) \
X(tomato)
// 生成变量定义
#define DEFINE_VAR(name) int name;
FRUITS(DEFINE_VAR)
// 生成字符串数组
#define STRINGIFY(name) #name,
char* names[] = { FRUITS(STRINGIFY) };
// 结果:
// int apple, banana, tomato;
// char* names[] = {"apple", "banana", "tomato"};
为什么这里不适用
插件系统过于动态,有些任务类型在编译时并不知道,只有运行时加载 DLL 后才出现
五、 C 与 C++ 的讨论
5.1 "C 是 C++ 的子集"------这是个误解
一个经典反例:
c
// 完全合法的 C 代码
void hello() {}
void hello() {}
cpp
// C++ 中可以函数重载,所以允许
void hello() {}
void hello(char* name) {} // OK
// 但在 C 中编译会报错:function redefinition
C 和 C++ 实际上是不同的语言
只是 C++ 设计时保持了语法兼容性,但这不意味着 C 代码在 C++ 编译器下能无缝工作。
5.2 C++ 的市场营销
Bjarne 大神是一位非常出色的营销者
C++ 成功的设计决策:
- 允许与 C 的二进制兼容(C 代码可以直接链接到 C++ 项目)
extern "C"机制让 C++ 可以无缝调用 C 库- 增量采用:不需要重写整个项目,可以逐步引入 C++
这种设计让 C++ 成为"最容易与现有 C 代码集成"的语言,而 Rust 在这方面相对困难。
六、C++ 对 Panyim 有帮助吗?
没有
问题 :C++ 的虚函数机制无法解决 DLL 热重载带来的函数指针失效问题
C++ 的虚表机制没有暴露出来让我们自定义,所以无法解决我们的问题。自定义虚表和 C++ 的虚函数相比,在这个特定场景下是更好的选择
| 维度 | C | C++ |
|---|---|---|
| 热重载时的虚函数问题 | 自定义虚表 ✓ 完全掌控 | 虚表不可访问/修改 × |
| 增量采用 | 可以混用 C 和 C++ | 可以 |
| 代码简洁度 | 需要 this-> 前缀 |
方法内可直接访问成员 |
| 内存管理 | Arena | 需要处理 unique_ptr 等 |
| 模板推导 | N/A | 常出现类型推导失败 |
附录
| 术语 | 解释 |
|---|---|
| Name Mangling | C++ 对函数名进行编码以支持重载的技术 |
extern "C" |
阻止 C++ 修饰函数名,用于与 C 代码交互 |
| Placement New | 在预分配内存上构造对象,不调用 malloc |
| X Macros | C 语言的编译时元编程技术,通过宏展开生成重复代码 |
| Virtual Table (vtable) | C++ 实现多态的内部机制,结构由编译器决定 |
Hot Reload |
程序运行时动态重新加载代码,常用于插件系统 |
- 初始化顺序 :C++ 构造函数的初始化列表按成员
声明顺序执行,而非书写顺序 - Virtual Distractor 问题 :基类有虚函数时
需要虚析构函数,否则删除子类对象时不会调用基类析构函数 - C++ Modules:C++ 仍未有好用的模块系统
- struct 初始化 :C++20 开始支持
std::initializer_list=={}构造临时 vector 再拷贝