UE 有两套互不兼容的"智能指针"系统,新手最常犯的错就是混用:给 UObject 套 TSharedPtr,或者给普通 C++ 对象写 UPROPERTY()。边界其实就一条规则——UObject 归 GC 管,用 TObjectPtr/裸指针/UPROPERTY;非 UObject 归引用计数管,用 TSharedPtr 家族。两套系统的内部实现完全不同,这篇把两边都拆开看。源码在 Runtime/Core/Public/Templates/SharedPointer.h 和 Runtime/CoreUObject/Public/UObject/ObjectPtr.h。
为什么不能给 UObject 用 TSharedPtr
TSharedPtr 的语义是"最后一个引用析构时销毁对象"。但 UObject 的销毁权在 GC 手里——GC 决定一个对象不可达时调 ConditionalBeginDestroy 走完整的销毁流程(回调通知、引用清理、进 GUObjectArray 的 slot 回收)。如果你把 UObject 塞进 TSharedPtr,引用计数归零时 delete 会直接析构对象,绕过 GC 的销毁流程:GUObjectArray 里的 slot 还挂着这个已析构对象的指针,下一轮 GC 遍历时直接崩。
反过来,非 UObject 的普通 C++ 类不能用 UPROPERTY()——UPROPERTY 的类型检查要求对象继承自 UObject,写了 UHT 会报错。两套系统是硬隔离的,编译期就能拦住大部分误用,真正危险的是绕过类型检查的裸指针互转。
TSharedPtr 的实现:分离的控制块
TSharedPtr 的结构(Templates/SharedPointerInternals.h)是经典的"指针 + 控制块"两段式,和 std::shared_ptr 同构但实现独立:
// 简化示意
template <typename ObjectType, ESPMode Mode>
class TSharedPtr {
ObjectType* Object; // 对象指针
SharedPointerInternals::FSharedReferencer<Mode> SharedReferenceCount; // 控制块引用
};
// 控制块(FReferenceControllerBase 派生)
class TReferenceController {
int32 SharedReferenceCount; // 强引用计数
int32 WeakReferenceCount; // 弱引用计数
ObjectType* Object; // 实际对象
// SharedReferenceCount 归零 → DestroyObject
// WeakReferenceCount 也归零 → delete 控制块本身
};
和 std::shared_ptr 不同的是 UE 默认不用 make_shared 式的合并分配:MakeShared<T>(Args...)(等价于 std::make_shared)存在且推荐用,但直接 TSharedPtr<T>(new T) 也常见,此时控制块和对象是两次独立堆分配。MakeShared 把对象和控制块放进同一块内存,少一次分配、缓存友好,但带来一个 UE 特有的坑:对象的内存要等弱引用也归零才释放(因为控制块里存着弱引用计数,和对象同块内存),长生命周期的弱引用会拖着对象内存不放。
ESPMode 模板参数是 UE 独有的:ESPMode::Fast(默认)的计数操作是普通整数自增,ESPMode::ThreadSafe 用原子操作(FPlatformAtomics)。ThreadSafe 只保证计数本身线程安全,不保证对象访问线程安全——两个线程同时拷贝同一个 TSharedPtr 是安全的(计数原子),但同时调 Get()->Modify() 依然数据竞争。这个区分和 std::shared_ptr 一致,但 UE 的 ESPMode::Fast 在跨线程场景下比 std::shared_ptr 更容易踩坑,因为默认就不是线程安全的。
TSharedRef:不可为空的强引用
TSharedRef 是"保证非空的 TSharedPtr":构造时必须传入有效指针,不提供默认构造和空状态,解引用不用判空。实现上就是 TSharedPtr 的包装加断言。它的存在是类型系统层面的契约表达——函数参数写 TSharedRef<FThing> 就是在签名里声明"我不会传空",比文档注释可靠。
转换是单向的:TSharedRef 能隐式转 TSharedPtr(安全,因为非空子集),反过来要显式调 .ToSharedRef() 且有运行时检查。API 设计上的惯例:能用 TSharedRef 就不用 TSharedPtr,省掉所有空调用分支。
TWeakPtr:只认控制块,不认对象
TWeakPtr 存的是控制块的弱引用,Pin() 返回一个 TSharedPtr——如果强引用计数已归零,Pin() 返回空。判空逻辑全在控制块里,不需要触碰对象本身——这就是弱引用"安全观察"语义的来源:对象内存可能已经被回收,但控制块还在(只要还有弱引用),Pin() 查计数就知道对象死没死。
TWeakPtr 只能由 TSharedPtr/TSharedRef 构造,不能独立存在。它和 UObject 系统的 TWeakObjectPtr 名字像但实现完全不同——后者不依赖控制块,靠的是 GUObjectArray 里的 serial number 机制(上一篇 GC 文章讲过)。把两者混为一谈是理解 UE 指针系统最大的认知障碍。
TObjectPtr:5.x 的对象指针包装
TObjectPtr(5.0 引入,替代裸 UObject* 作为 UPROPERTY 的推荐类型)是 GC 阵营的改进,不是引用计数。它的内部是:
// 简化示意(UObject/ObjectPtr.h)
template <typename T>
class TObjectPtr {
FObjectHandle Handle; // 编辑器下是 handle,Shipping 下退化为原始指针
// FObjectHandle 内部是 GUObjectArray 的 index
};
行为分两套:
- 编辑器/开发构建:
TObjectPtr存的是FObjectHandle(GUObjectArray索引),解引用时查表拿实际指针。这带来两个能力:编辑器能追踪"谁正在访问这个对象"(FObjectHandle的访问钩子),以及对象重定向(懒加载、Packer 里的对象重映射)可以只改表项不改所有指针。 - Shipping 构建:
FObjectHandle直接typedef成T*,TObjectPtr零开销退化为裸指针。
所以这个包装是编辑器能力 + 运行时零开销的组合,代价是赋值路径上挂了写屏障(GC 增量标记用,上一篇讲过)和调试钩子,比裸指针略贵但换来正确性。5.x 的 UPROPERTY 应该一律写 TObjectPtr<UMyClass>,裸 UObject* 成员只在局部变量和函数参数上用。
选型决策
实战结论
- 第一原则永远成立:UObject 用 TObjectPtr/UPROPERTY,非 UObject 用 TSharedPtr 家族,两套混用必然崩,区别只在崩在 GC 阶段还是析构阶段。
MakeShared优先于裸new+ 构造,但要知道它让对象内存和弱引用计数共存——长生命周期弱引用场景(缓存、观察者列表)要注意内存驻留。TSharedRef做函数参数比TSharedPtr好,签名即契约,省掉所有空分支。- 5.x 的 UPROPERTY 一律
TObjectPtr,别写裸UObject*成员;函数参数和局部变量用裸指针没问题,反射系统只需要看到成员声明。 - 排查"弱引用 Pin 出来是空但对象明明在"先确认:你
Pin的那个TWeakPtr是不是从另一个TSharedPtr拷贝链来的——控制块不共享(比如从原始指针新构造了TSharedPtr)时,Pin查的是另一套计数,永远为空。
指针管生命周期,数据管布局。下一篇看 UE 的容器(TArray/TMap)和分配器怎么决定数据的内存行为。