matxscript运行时系统之对象模型
仓库链接:bytedance/matxscript: A high-performance, extensible Python AOT compiler.
概述
matxscript运行时系统的核心基础是其基于引用计数的对象模型。该模型提供了自动内存管理、类型安全和面向对象特性支持,为整个运行时环境奠定了坚实的基础。对象模型主要由三个核心组件构成:
- Object类:作为所有对象容器的基类,包含了类型索引、引用计数器和删除器等关键字段
- ObjectPtr模板类:一个自定义智能指针,用于管理Object实例的安全访问和生命周期控制
- ObjectRef类:对象引用类,提供给外部用户使用的接口
这套设计借鉴了Apache TVM的理念,实现了高效且灵活的对象管理系统,支持继承、多态和运行时类型检查等高级特性。
核心组件
Object基类
Object类(定义于include/matxscript/runtime/object.h)是整个对象系统的根基。它包含以下关键成员变量:
type_index_:标识对象类型的唯一索引ref_counter_:引用计数器,用于跟踪有多少个智能指针正在引用此对象deleter_:函数指针,在引用计数归零时调用以释放资源
1 | class Object { |
Object类还提供了几个重要的方法:
type_index():获取当前对象的类型索引GetTypeKey():返回对象类型的字符串表示IsInstance<T>():模板方法,用于在运行时检查对象是否是指定类型的实例
1 | // Implementations details below |
ObjectPtr智能指针
ObjectPtr<T>是一个模板类,专门用来管理继承自Object的对象实例。它通过重载运算符(如*、->)提供了类似原生指针的操作体验,同时自动化处理引用计数逻辑。
关键特性包括:
- 构造或复制时增加引用计数
- 销毁或重新赋值时减少引用计数
- 当引用计数降至0时自动调用对象的析构函数并释放内存
1 | template <typename T> |
ObjectRef引用类
ObjectRef是对ObjectPtr的一层封装,主要用于向用户提供统一的对象访问接口。它隐藏了底层智能指针的具体实现细节,使得用户可以更方便地操作各种类型的对象实例。
ObjectRef最重要的功能之一就是类型转换——通过as<T>()方法可以在运行时安全地将通用对象引用转换为特定类型的引用。
1 | /*! \brief Base class of all object reference */ |
拷贝时调 IncRef(),析构/reset 时调 DecRef(),本质就是 引用计数智能指针 的标准模式。下面直接用代码路径一步步拆开,让你能在脑中“看到”计数的变化。
自定义智能指针体系
核心是三层:
**Object**:所有“节点对象”的基类,内置引用计数 + 删除器**ObjectPtr<T>**:指向**Object**的智能指针,负责增减引用计数**ObjectRef**:对外暴露的“句柄类型”(List、Dict、String 等都继承它),内部持有**ObjectPtr<Object>**
对象的最小单元:Object 的计数字段
在 Object 里有两个关键字段:
1 | class Object { |
- 任意
Object派生类(比如ListNode)在构造完成时,ref_counter_ = 1。 - 后续所有“持有这个对象”的智能指针,都围绕这一个
ref_counter_做加减。
引用计数的核心:IncRef / DecRef
IncRef:增加引用数
1 | inline void Object::IncRef() noexcept { |
含义:又多了一个“拥有者”(一个新的 ObjectPtr 指向该对象)。
DecRef:减少引用数并在 0 时析构
非原子版本(逻辑最简单):
1 | inline void Object::DecRef() noexcept { |
含义:
--ref_counter_:当前ObjectPtr不再持有该对象,多一个指针销毁 / reset。- 如果减完后计数变为 0:说明再没有任何
ObjectPtr指向它:
- 调用
deleter_(this)释放对象(类似delete this)。
原子版本只是加了一些内存序和优化分支,语义一样:最后一个引用被销毁时才真正释放对象。
ObjectPtr 的行为:在哪里调用 IncRef / DecRef
关键点:ObjectPtr<T> 是唯一对 Object::IncRef / DecRef 直接操作的类。
内部只有一个裸指针:
1 | template <typename T> |
从原始 Object* 构造时:IncRef
1 | explicit ObjectPtr(Object* data) noexcept : data_(data) { |
谁会调用这个构造函数?
GetRef/GetObjectPtr等内部接口;make_object之后,封装到ObjectPtr时。
效果:每创建一个新的智能指针“拥有者”,引用计数 +1。
拷贝构造时:IncRef
1 | ObjectPtr(const ObjectPtr<T>& other) noexcept |
- 拷贝构造直接复用
ObjectPtr(Object*)构造函数。 - 所以每次拷贝
ObjectPtr,都会对other.data_调用IncRef()。
模板版本同理(从 ObjectPtr<U> 拷贝到父类 ObjectPtr<T>),一样加计数:
1 | template <typename U> |
=> “拷贝时 IncRef()” 就是在这里实现的。
析构 / reset 时:DecRef
1 | ~ObjectPtr() { |
- 每一个
ObjectPtr在生命周期结束(离开作用域、被销毁)时调用~ObjectPtr(); - 析构调用
reset(),reset()再调用DecRef()。
=> “析构/reset 时 DecRef()” 就在这里实现。
赋值运算符:用 copy-and-swap 保证计数平衡
1 | ObjectPtr<T>& operator=(const ObjectPtr<T>& other) noexcept { |
以拷贝赋值为例说明计数变化:
1 | ObjectPtr<T> a = ...; // a 持有 X,ref_count(X) = 1 |
执行过程:
ObjectPtr(other):构造一个临时tmp,其中tmp.data_ = a.data_:
- 调用
IncRef(),所以ref_count(X)从 1 -> 2;
tmp.swap(*this):交换tmp.data_和b.data_:
- 此时
b.data_指向 X,tmp.data_指向原来的 Y;
- 语句结束,
tmp析构:
- 在
~ObjectPtr()中调用reset(),对Y调用DecRef(): ref_count(Y)1 -> 0,触发删除 Y。
结果:
- X 被 a 和 b 共享,计数 = 2;
- Y 无人再引用,被释放。
计数始终平衡:谁新持有(拷贝)就 +1,谁不再持有(析构/覆盖)就 -1。
句柄层(ObjectRef, List 等)的拷贝/析构如何影响计数
ObjectRef 内部就是一个 ObjectPtr<Object>:
1 | class ObjectRef { |
以 List 为例,通过宏生成的方法类似:
1 | class List : public ObjectRef { |
行为:
-
拷贝
List: -
调用
List(const List&),成员data_执行拷贝构造; -
data_是ObjectPtr<Object>,其拷贝构造会调用IncRef()。 -
List析构: -
data_析构,调用ObjectPtr::~ObjectPtr()->reset()->DecRef()。
因此,“拷贝 List -> IncRef(),销毁 List -> DecRef()”是通过 ObjectPtr 这层自动完成的。
用例:画一条完整的计数变化时间线
假设有一个底层节点 ListNode,其 ref_counter_ 初始为 1(刚创建,暂不包到 ObjectPtr 里先忽略这一步的构造逻辑,直接从第一个 ObjectPtr 开始考虑):
1 | List a = some_function(); // a 内部有一个 ObjectPtr,指向节点 N |
计数变化:
List a = ...;
- 创建一个
ObjectPtr<Object>指向 N,调用IncRef(): - 假设创建时从原始裸指针开始,N: 1 → 1(构造初始化已经 1,不再重复考虑这一细节),总之最终
use_count()为 1 或 2,取决于 make_object 实现,这里简化为“此刻 ref_count(N) = 1”。
List b = a;
- 拷贝构造
List,data_拷贝构造: ObjectPtr拷贝构造调用IncRef():ref_counter(N) : 1 → 2。
c = a;
-
c.data_原本是空指针,拷贝赋值等价于: -
构造临时
ObjectPtr tmp(a.data_):ref_counter(N) : 2 → 3; -
swap(tmp, c.data_):c.data_指向 N; -
tmp析构,因 tmp 原来持有的是nullptr,因此这里对 N 无影响(这里简化理解;如果 c 原来持有别的对象,也会在 tmp 析构时对那个对象做DecRef())。 -
结果:
ref_counter(N) = 3(a、b、c 都指向 N)。
b = List();
List()默认构造,内部的data_是一个默认构造的ObjectPtr<Object>。- 默认构造的
ObjectPtr内部data_ = nullptr,不会对任何Object调IncRef()。 - 因此,创建
List()这一步对 N 完全没有影响,ref_count(N)仍然是 3。 - 记住这一点:右值
List()的内部指针是nullptr。 - 为什么?
1 | explicit ObjectPtr(Object* data) noexcept : data_(data) { |
swap(tmp, b.data_):b.data_ 改指向新的(或空);tmp析构,对旧的 N 调用DecRef():ref_counter(N) : 3 → 2;- 综合起来其实就是 b 由指向 N → 指向其它(或空),N 的计数从 3 → 2。
- 作用域结束:
-
c析构:c.data_析构,DecRef():ref_counter(N): 2 → 1; -
a析构:DecRef():ref_counter(N): 1 → 0,触发deleter_(this),释放 N; -
其他临时对象的析构一般不会再影响 N(已经释放了)。
-
b.data_ == nullptr,即b变成空引用; -
a和c仍然指向 N; -
N 的引用计数从 3 变为 2。
所以:
- 每增加一个活跃的句柄 / 智能指针,计数 +1;
- 每一个句柄 / 智能指针消失或不再指向该对象,计数 -1;
- 当最后一个引用释放时,计数到 0,删除对象。
总结成一句话
-
ObjectPtr是唯一操作Object::IncRef / DecRef的封装: -
拷贝构造 / 从原始
Object*构造时:IncRef(); -
析构 /
reset()/ 赋值导致旧指针被替换时:DecRef(); -
ObjectRef(以及List等所有容器句柄)内部只是持有一个ObjectPtr,靠默认的拷贝/析构行为把这些加减计数“传递”到Object上; -
这样设计保证了:对象只在无人引用时自动释放,多处引用时共享同一份数据。
类型系统
matxscript的类型系统允许开发者创建具有明确类型信息的对象,并能在运行时进行准确的类型识别。这一能力依赖于几个关键机制:
静态类型信息
每个具体对象类都需要声明一组静态常量来描述其类型属性:
_type_key:全局唯一的类型名称字符串_type_index:预分配的类型索引或者TypeIndex::kDynamic(表示动态分配)_type_final:标记此类是否可被进一步继承_type_child_slots:预留的孩子类型数量,用于优化运行时类型检查性能
例如:
1 | class MyObject : public Object { |
类型注册宏
为了使新定义的对象类型能够在运行时被正确识别,需要使用相应的宏来进行注册:
MATXSCRIPT_DECLARE_BASE_OBJECT_INFO(TypeName, ParentType):用于声明可继承的对象类型MATXSCRIPT_DECLARE_FINAL_OBJECT_INFO(TypeName, ParentType):用于声明不可继承的最终对象类型MATXSCRIPT_REGISTER_OBJECT_TYPE(TypeName):在源文件中实际执行类型注册操作
这些宏会生成必要的辅助函数和初始化代码,确保类型体系能够正常工作。
运行时类型上下文
真正的类型管理是在TypeContext类(位于src/runtime/object.cc)中完成的。这个单例模式的类维护了一个全局类型表,记录着每种已知类型的信息,包括它们的名称、父类型关系、可用槽位等元数据。
当首次请求某个动态类型索引时,系统会在互斥锁保护下为其分配一个新的编号,并将其相关信息存入类型表中供后续查询使用。
1 | struct TypeInfo { |
TypeContext 这个类的作用,可以概括成一句话:
它是一个全局的“类型注册与查询中心”,用来维护运行时的类型层次结构(继承关系)以及类型名和类型索引之间的映射。
下面按功能拆开讲:
维护类型层次结构(父子关系)
核心思想:每个类型在运行时都有一个 uint32_t 的类型索引(type index),并且知道自己的父类型索引,从而形成一棵类型继承树。
-
type_table_是一个std::vector<TypeInfo>,每个TypeInfo表示一个类型的信息: -
index:当前类型的索引 -
parent_index:父类型的索引 -
num_slots/allocated_slots:为子类型预留的“类型槽位”及已使用数量 -
child_slots_can_overflow:是否允许超过预留槽位继续扩展 -
name/name_hash:类型名称及其哈希 -
构造函数中:
1 | TypeContext() { |
初始化了一个类型表,索引 0 是根类型 "runtime.Object"。
判断继承关系:DerivedFrom
1 | bool DerivedFrom(uint32_t child_tindex, uint32_t parent_tindex) |
用途:判断一个类型(child_tindex)是否是另一个类型(parent_tindex)的子类或本身。
实现方式:
- 利用不变式:子类的 type index 一定大于父类
- 如果
child_tindex < parent_tindex→ 肯定不是子类,返回false - 如果相等 → 是同一类型,返回
true
- 在互斥锁保护下,从子类型开始不断沿
parent_index往上跳:
1 | while (child_tindex > parent_tindex) { |
最终判断能否追溯到父类型。
注释里也说了:这是一个相对慢的路径,真正高频的类型检查通常用更快速的“保留槽位”机制来判断。
注册/分配运行时类型索引:GetOrAllocRuntimeTypeIndex
1 | uint32_t GetOrAllocRuntimeTypeIndex(const string_view& skey, |
用途:**给一个类型名(skey)分配一个运行时类型索引,并记录它的父类、子类槽位信息等。**这个函数也负责处理“静态分配”和“动态分配”的两种情况。
步骤:
- 先查
type_key2index_,如果已经注册,直接返回原来的 type index。 - 否则,需要新注册:
- 确认
parent_tindex合法,拿到父类型pinfo - 如果父类不能 overflow 子槽 (
!pinfo.child_slots_can_overflow),那么子类也不能
- 计算当前类型需要占用的槽位数(自身 + 子槽):
1 | uint32_t num_slots = num_child_slots + 1; |
- 决定
allocated_tindex(当前类型的索引):
-
静态类型(
static_tindex != TypeIndex::kDynamic) 直接使用编译期给定的static_tindex,并检查该位置没有被占用。 -
动态类型:从父类的预留槽中分配:
-
如果
pinfo.allocated_slots + num_slots <= pinfo.num_slots,就在父类的预留范围内分配 -
否则,如果父类允许溢出(overflow),则在
type_table_尾部扩展一个新的区间作为新类型的槽位 -
如果不允许 overflow,则直接报错(达到子类型最大数量)
- 对新类型在
type_table_中进行初始化:
- 设置
index,parent_index,num_slots,allocated_slots=1 - 设置
child_slots_can_overflow - 填写
name和name_hash
- 更新
type_key2index_映射,返回分配到的allocated_tindex。
这部分逻辑使得系统可以:
- 有计划地为某些类型预留固定数量的“子类型位置”(有利于快速类型判断和布局)
- 在必要时支持扩张(overflow),不硬性限制子类型数量
类型名与索引互查
索引 → 名称
const String& TypeIndex2Key(uint32_t tindex)bool TryTypeIndex2Key(uint32_t tindex, string_view* tkey)size_t TypeIndex2KeyHash(uint32_t tindex)
功能:
- 根据类型索引拿到类型名称或名称哈希
- 会检查该索引是否在表内且已经分配(
allocated_slots != 0),否则报错或返回false
名称 → 索引
uint32_t TypeKey2Index(const string_view& skey)
功能:
- 根据类型名查找对应的 type index
- 如果找不到,会提示是否忘记注册该类型(通过宏
MATXSCRIPT_REGISTER_NODE_TYPE)
调试输出类型树:Dump
1 | void Dump(int min_children_count) |
用途:方便调试,输出所有子类数量不少于 **min_children_count** 的类型,以及它们的父类、预留子槽数量等。
实现:
- 创建
num_children数组,记录每个类型的所有子孙数目 - 从
type_table_末尾往前累加(逆序遍历),实现自底向上的统计 - 输出符合条件的类型信息到
std::cerr
全局单例:Global
1 | static TypeContext* Global() { |
TypeContext被设计为进程内的全局单例,所有类型注册和查询都通过TypeContext::Global()访问。- 内部用
std::mutex保证多线程注册/查询的线程安全。
TypeContext 的主要作用是:
- 作为一个全局的运行时类型系统管理器;
- 管理:
- 类型索引(type index)
- 类型名称
- 父子继承关系
- 预留子类型槽位与溢出策略
- 提供接口完成:
- 注册/分配新类型索引
- 判断继承关系(
DerivedFrom) - 类型名与索引之间的互相转换
- 打印调试信息(类型树结构)
下面用一个简化的“伪使用示例”来说明 TypeContext 的使用流程:注册几个类型,然后做继承判断与查询。
假设有这样的类型关系:
-
runtime.Object(根,索引 0,系统已初始化好) -
MyBase -
MyChild
我们在 C++ 里模拟一下操作(伪代码,接近真实用法):
1 |
|
要点说明:
GetOrAllocRuntimeTypeIndex的参数含义:
-
skey:类型的字符串标识(通常就是类型名) -
static_tindex: -
如果你为类型预先安排了固定索引(编译时约定),传这个值
-
如果没有固定索引,则传
TypeIndex::kDynamic,由系统动态分配 -
parent_tindex:父类型的索引,用来建立继承关系 -
num_child_slots:为这个类型的子类预留多少槽位 -
child_slots_can_overflow:当预留槽位用完后,是否允许继续扩展
- 一旦类型注册成功:
- 可以通过
TypeKey2Index/TypeIndex2Key做名称与索引的双向转换 - 可以通过
DerivedFrom(child, parent)判断任何两种类型是否存在“子类/祖先”关系
- 实际工程中通常不会手写调用
GetOrAllocRuntimeTypeIndex,而是通过宏MATXSCRIPT_REGISTER_NODE_TYPE(MyType)之类的封装宏进行注册; 宏内部会调用TypeContext::Global()->GetOrAllocRuntimeTypeIndex(...)。
如果你给我看一下 TypeInfo 结构体的定义,我可以再结合它把“预留子槽”的布局画成一张小图,帮助理解索引分配策略。
内存管理
matxscript采用基于引用计数的自动内存管理模式。每当一个新的ObjectPtr指向某个对象时,该对象的引用计数就会加一;反之,当某个ObjectPtr销毁或不再指向该对象时,引用计数减一。一旦引用计数变为零,就意味着没有任何活跃的引用存在了,此时就会触发对象的清理过程。
这种方案有几个显著优点:
- 自动化程度高,减轻程序员负担
- 允许跨线程共享对象所有权(前提是使用原子引用计数)
- 相比垃圾收集机制延迟更低,更加可预测
不过也需要注意循环引用问题可能导致内存泄漏,这要求开发者在设计对象间关系时格外小心。
继承与多态
借助精心设计的类型系统,matxscript很好地支持了面向对象编程中的继承和多态概念。
子类只需按照规定格式填写自己的类型信息并注册到系统中即可成为合法的对象类型。运行时可以通过IsInstance<T>()方法判断任意对象是否属于指定类型或其派生类型。
此外,配合使用Downcast<T>(ref)函数还能实现在保持类型安全性的同时进行向上转型操作。
总结
matxscript运行时的对象模型是一套完整而优雅的设计方案,兼顾了效率、灵活性和易用性。通过对引用计数技术的应用,实现了高效的内存管理;通过丰富的类型信息支持,赋予了强大的运行时类型识别能力;再加上良好的继承体系构建,极大地提升了代码复用率和模块化水平。无论是对于底层基础设施建设还是上层应用开发来说,都是极其宝贵的资产。
- 标题: matxscript运行时系统之对象模型
- 作者: 鱿鱼圈
- 创建于 : 2025-11-23 23:50:00
- 更新于 : 2026-06-12 17:14:28
- 链接: https://yuyanqi.com/2025/11/23/matxscript对象模型/
- 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。