[UE C++] 智能指针——非UObject
概念
智能指针是为了方便程序员管理堆内存而提出来的,核心思想是:程序员只管分配堆内存,释放内存交给智能指针。UE有自己的一套内存管理系统,但这只针对于UObject。为了方便对非UObject对象的内存管理,UE设计了一套自己的智能指针库,包含:共享指针(TSharedPtr)、弱指针(TWeakPtr) 和 独占指针(TUniquePtr),以及特有的共享引用(TSharedRef)。关于UE的智能指针库,有如下解释(来自源码注释)
好处:- 简洁的语法:您可以复制、解引用和比较Shared pointers,就像普通的c++指针一样
- 防止内存泄漏:当对象没有被引用时,资源将自动销毁
- 弱引用:Weak pointers 允许您安全地检查对象何时被销毁
- 线程安全:虚幻智能指针库包括线程安全代码,可跨线程管理引用计数。如无需线程安全,可用其换取更好性能。
- 普遍性:你可以创建一个Shared pointers几乎指向任何类型的对象
- 运行时安全:Shared references永远不会为空,并且总是可以取消引用
- 无循环引用:使用Weak pointers可以阻止循环引用
- 赋予意图:你可以轻松的分辨"owner"与"observer"
- 性能:开销很小,所有的操作都是常数时间
- 强大的功能:支持'const',前向声明不完整类型,类型转换等
- 内存:智能指针在64位下仅为C++指针大小的两倍(加上共享的16字节引用控制器)。独占指针除外,其与C++指针大小相同。
- 限制:
- 不兼容UObject对象(无法指向UObject对象,但存在UObject的智能指针)
- 目前只有具有常规析构函数的类型(没有自定义deleters)
- 还不支持动态分配数组(例如MakeSharable(new int32[20]))
- 目前不支持将TSharedPtr/TSharedRef隐式转换为bool(explicit operator bool)
- 实现区别:
- 类型名和方法名与虚幻的代码库更加一致
- 必须使用Pin()将弱指针转换为共享指针(没有显式构造函数)
- 线程安全特性是可选的,而不是强制的
- TSharedFromThis返回一个Shared Reference,而不是一个Shared Pointer
- 一些特性被省略了(例如use_count(), unique()等)
- 不允许抛出异常
- 自定义的allocators与delete暂不支持
- 支持非空智能指针(TSharedRef)
- 增加了MakeShareable,并可以将nullptr赋值给智能指针
- UE为什么要实现自己的智能指针
- std不能全平台通用
- 允许在所有编译器和平台上更一致的实现
- 可以和UE其它容器无缝衔接
- 更好地控制平台细节,包括线程和优化
- 希望线程安全特性是可选的(为了性能)
- 添加自己的改进(MakeShareable, assign to nullptr等)
- 不需要也不希望出现异常
- 希望对性能有更多的控制(内联、内存、虚拟的使用等)
- 更容易调试(自由代码注释等)
- 不需要的时候不要引入新的第三方依赖项
测试类:
class TestClass { public: int a = 0; float b = 0.f; TestClass() { a = 0; b = 0.f; } TestClass(int a, float b) { this->a = a; this->b = b; } ~TestClass() { UE_LOG(LogTemp, Warning, TEXT("Delete:%d"),a); } };
1. TSharedPtr
允许多个共享指针指向同一块内存,内部采用引用计数的方式,拷贝和赋值会使引用计数+1,析构会使引用计数-1。当引用计数为0时,会释放指向对象的内存。
1.1 创建
常见的创建并初始化的方式如下
TestClass* NativePtr = new TestClass(1,2.f); //默认构造 TSharedPtr<TestClass> SharedPtr; //原生指针左值初始化 TSharedPtr<TestClass> SharedPtr1(NativePtr); NativePtr = nullptr; //原生指针右值初始化 TSharedPtr<TestClass> SharedPtr2(new TestClass(2,3.f)); //原生指针初始化,自定义Deleter TSharedPtr<TestClass> SharedPtr3( new TestClass(2,3.f), [](TestClass* deleteObject) { UE_LOG(LogTemp, Warning, TEXT("SharedPtr2:%d\t%f"), deleteObject->a, deleteObject->b); delete deleteObject; } ); //拷贝构造 TSharedPtr<TestClass> SharedPtr4(SharedPtr1); //MakeShareable将原生指针转化为智能指针 TSharedPtr<TestClass> SharedPtr5(MakeShareable(new TestClass(2, 3.f))); //MakeShareable,自定义Deleter TSharedPtr<TestClass> SharedPtr6 = MakeShareable( new TestClass(2, 3.f), [](TestClass* deleteObject) { UE_LOG(LogTemp, Warning, TEXT("SharedPtr2:%d\t%f"), deleteObject->a, deleteObject->b); delete deleteObject; } ); //MakeShared构造智能指针,推荐做法 TSharedPtr<TestClass> SharedPtr7(MakeShared<TestClass>(1,2.f)); //线程安全的共享指针 TSharedPtr<TestClass, ESPMode::ThreadSafe> SharedPtr8(new TestClass(2, 3.f));
不推荐通过原生指针左值初始化的方式,原因在于原生指针和智能指针混用是一件很危险的事情,通过原生指针操作对象的内存,智能指针是无法检测到的,若确实要用应立马将原生指针置为nullptr:
NativePtr = nullptr
TSharedPtr并没有实现对 数组的偏特化T[] ,不推荐引用对象类型为数组。
//报错 TSharedPtr<int[]> sp3{ new int[3]{10,11,12} };
如果确实想要引用数组类型的对象,你可能需要这么做。
TSharedPtr<int> sp3{ new int[3]{10,11,12} ,[](int* deleteArr) { delete[] deleteArr; } }; //打印12 UE_LOG(LogTemp, Warning, TEXT("%d"), *(sp3.Get()+2));
MakeShareable也没有实现 数组的偏特化T[] 版本
1.2 常用接口
//返回引用对象的原生指针 SharedPtr.Get(); //转化为TSharedRef,会使引用计数+1。必须保证SharedPtr.Get() != nullptr,不然会触发Assert SharedPtr.ToSharedRef(); //获得引用计数,int32 SharedPtr.GetSharedReferenceCount(); //判断是否是唯一的共享指针(即GetSharedReferenceCount() == 1) SharedPtr.IsUnique(); //判断共享指针是否指向了一个有效对象(即SharedPtr.Get() != nullptr) SharedPtr.IsValid(); //重置此共享指针,会使引用计数-1 SharedPtr.Reset();
1.3 运算符
// -> SharedPtr->a; // * ,注意这个(),优先级问题 (*SharedPtr4).a; // = ,会使引用计数发生变化,SharedPtr2指向的+1,若SharedPtr1原来指向的对象不会空,则-1 SharedPtr1 = SharedPtr2; //相当于SharedPtr1.Reset(); SharedPtr1 = nullptr; // bool,相当于if(SharedPtr.IsValid()) if(SharedPtr) if(!SharedPtr)
1.4 注意事项
- TSharedPtr可以指向nullptr
- 不推荐使用原生指针初始化共享指针
- 自定义Deleter需要自行处理内存释放逻辑
2. TSharedRef
共享引用,与TSharedPtr相比区别点在于:TSharedRef指向的必须是有效对象,不能为空值。与TSharedPtr同时使用时,会共用引用计数器。
操作与TSharedPtr大致相同,下面列出区别点
- 无默认构造函数
- 无IsValid()
- 无Reset()
- 没有重载bool运算符
与TSharedRef可以相互转化
- TSharedRef->TSharedPtr:隐式转化 SharedPtr = SharedRef
- TSharedPtr->TSharedRef:SharedRef = SharedPtr.ToSharedRef(),且必须保证SharedPtr.IsValid() == true
注意事项:
- TSharedRef虽然叫共享引用,但是解引用的操作方式还是 “->” 与 “*”,而非 “.”。
- TSharedRef虽然不能为空,但是可以更换引用的对象
- TSharedRef.Get()返回的是引用对象而不是引用对象的指针
3. TWeakPtr
弱指针,为了解决循环引用。只对引用对象保留引用权,并不参与引用计数。当需要操作引用对象时,需转化为TSharedPtr来使用
3.1 创建
//默认构造 TWeakPtr<TestClass> WeakPtr; //拷贝构造 TWeakPtr<TestClass> WeakPtr1(WeakPtr); //TSharedPtr TWeakPtr<TestClass> WeakPtr2(SharedPtr); //TSharedRef TWeakPtr<TestClass> WeakPtr3(SharedRef);
3.2 常用接口
//返回SharedPtr,使用前应进行有效性判断 WeakPtr.Pin(); //重置这个弱指针,或=nullptr WeakPtr.Reset(); //如果此弱指针指向的对象与指定的对象指针相同,则返回true。 WeakPtr.HasSameObject(NativePtr); //检查这个弱指针是否确实有对对象的有效引用 WeakPtr.IsValid();
3.3 运算符
// = ,会产生弱引用 WeakPtr = OtherWeakPtr; WeakPtr = SharedRef; WeakPtr = SharedPtr; //相当于WeakPtr.Reset(); WeakPtr = nullptr;
4. TUniquePtr
独占指针,需要将对象的生命周期严格绑定到单个智能指针的生命周期时使用,保证对象被唯一引用。这个类是不可复制的——所有权只能通过move操作转移,例如:
TUniquePtr<MyClass> Ptr1(new MyClass); // The MyClass object is owned by Ptr1. TUniquePtr<MyClass> Ptr2(Ptr1); // Error - TUniquePtr is not copyable TUniquePtr<MyClass> Ptr3(MoveTemp(Ptr1)); // Ptr3 now owns the MyClass object - Ptr1 is now nullptr.
// Non-copyable TUniquePtr(const TUniquePtr&) = delete; TUniquePtr& operator=(const TUniquePtr&) = delete;
4.1 创建
TUniquePtr不存在线程安全版本
//默认构造 TUniquePtr<TestClass> UniquePtr; //原生指针右值 TUniquePtr<TestClass> UniquePtr1(new TestClass(2, 3.f)); //原生指针左值 TUniquePtr<TestClass> UniquePtr2(NativePtr); //MakeUnique() TUniquePtr<TestClass> UniquePtr3(MakeUnique<TestClass>(5, 6.f));
TUniquePtr也可以自定义Deleter,但是需要创建一个struct并重载 () 运算符,只能通过原始指针构造。
struct TTestDelete { void operator()(TestClass* DelePtr) const { UE_LOG(LogTemp, Warning, TEXT("Custom Delete UniquePtr:%d"), DelePtr->a); delete DelePtr; } }; //注意这里是原始指针,若用MakeUnique<TestClass>(2, 3.f)会报错 TUniquePtr<TestClass, TTestDelete> UniquePtr2(new TestClass(2, 3.f));
TUniquePtr实现了 数组的偏特化T[] ,可以很方便的对数组变量进行引用
TUniquePtr<int32[]> up1(new int32[3]{ 1, 2, 3 });
MakeUnique()也实现了 数组的偏特化T[],但是输入参数是数组的Size,而不是对象的初始化参数。需要显式定义无参构造函数
//创建一个数组大小为4的使用无参构造函数的TUniquePtr[] TUniquePtr<TestClass[]> UniquePtr4(MakeUnique<TestClass[]>(4));
缺省的Deleter,数组类型最后会调用
Delete[] ptr
,单个对象会调用Delete ptr
。若引用对象为数组,希望自定义Deleter,可以这样做。struct TTestDeleteArray { void operator()(TestClass *DeleteClass ) const { UE_LOG(LogTemp, Warning, TEXT("Custom Delete UniquePtr Array")); //区别点 delete[] DeleteClass; } }; TUniquePtr<TestClass[], TTestDeleteArray> UniquePtr5(new TestClass[4]{});
4.2 常用接口
//返回一个指向被拥有对象的指针,但不放弃所有权。 UniquePtr.Get(); //返回一个指向被拥有对象的指针,放弃所有权,但不会delete以前拥有的对象。 UniquePtr.Release(); //给TUniquePtr一个要拥有的新对象,delete以前拥有的对象。可以不输入参数,default = nullptr UniquePtr.Reset(); //检查TUniquePtr当前是否拥有一个对象。 UniquePtr.IsValid(); //返回对Deleter的引用 UniquePtr.GetDeleter();
4.3 运算符
// = ,只接受右值类型的TUniquePtr,一般配合MoveTemp转移对资源的控制权。相当于UniquePtr1.Reset(UniquePtr2) UniquePtr1 = MoveTemp(UniquePtr2); //相当于UniquePtr.Reset() UniquePtr = nullptr; // bool 相当于 if(UniquePtr.IsValid()) if(UniquePtr) 与 if(!UniquePtr) // 单个对象:*,-> (*UniquePtr).a; UniquePtr->a; // 数组对象:[] UniquePtr[0].a
5. 辅助运算符
通过Ptr.Get()判断或Ptr.IsValid()。==或!=两边变量可交换顺序
SharedRef1 == SharedRef2; SharedRef1 != SharedRef2; SharedPtr1 == SharedPtr2; SharedPtr1 == nullptr; SharedPtr1 != SharedPtr2; SharedPtr1 != nullptr; SharedPtr == SharedRef; SharedPtr != SharedRef; WeakPtr1 == WeakPtr2; WeakPtr1 == nullptr; WeakPtr1 != WeakPtr2; WeakPtr1 != nullptr; WeakPtr == SharedRef; WeakPtr == SharedPtr; WeakPtr != SharedRef; WeakPtr != SharedPtr; UniquePtr1 == UniquePtr2; UniquePtr1 != UniquePtr2; UniquePtr1 == nullptr; UniquePtr1 != nullptr;
6. TSharedFromThis
在某些情况下,可能在某个函数中返回类对象的this指针,并在类外被外部变量使用。这就导致当类对象被delete后,外部变量可能不知道类对象已被销毁,触发空指针引用。UE提供了TSharedFromeThis来解决类似问题,类似于std中的std::enable_shared_from_this。
TSharedFromThis是一个模板类,需要继承,提供了两个接口函数用于得到对象的 SharedRef:
AsShared
和SharedThis
。也存在线程安全版本,可添加ESPMode::ThreadSafe。class TestClass: public TSharedFromThis<TestClass, ESPMode::ThreadSafe> { public: int a = 0; float b = 0.f; TestClass() { a = 0; b = 0.f; } TestClass(int a, float b) { this->a = a; this->b = b; } ~TestClass() { UE_LOG(LogTemp, Warning, TEXT("Delete:%d"),a); } };
AsShared 会将类返回为最初作为模板参数传到 TSharedFromThis 的类型返回,其可能是调用对象的父类型。而 SharedThis 将直接从该类型衍生类型,并返回引用该类型对象的智能指针。以下范例代码中演示这两种函数:
class FRegistryObject; class FMyBaseClass: public TSharedFromThis<FMyBaseClass> { virtual void RegisterAsBaseClass(FRegistryObject* RegistryObject) { // 访问对"this"的共享引用。 // 直接继承自< TSharedFromThis >,因此AsShared()和SharedThis(this)会返回相同的类型。 TSharedRef<FMyBaseClass> ThisAsSharedRef = AsShared(); // RegistryObject需要 TSharedRef<FMyBaseClass>,或TSharedPtr<FMyBaseClass>。TSharedRef可被隐式转换为TSharedPtr. RegistryObject->Register(ThisAsSharedRef); } }; class FMyDerivedClass : public FMyBaseClass { virtual void Register(FRegistryObject* RegistryObject) override { // 并非直接继承自TSharedFromThis<>,因此AsShared()和SharedThis(this)不会返回相同类型。 // 在本例中,AsShared()会返回在TSharedFromThis<> - TSharedRef<FMyBaseClass>中初始指定的类型。 // 在本例中,SharedThis(this)会返回具备"this"类型的TSharedRef - TSharedRef<FMyDerivedClass>。 // SharedThis()函数仅在与 'this'指针相同的范围内可用。 TSharedRef<FMyDerivedClass> AsSharedRef = SharedThis(this); // FMyDerivedClass是FMyBaseClass的一种类型,因此RegistryObject将接受TSharedRef<FMyDerivedClass>。 RegistryObject->Register(ThisAsSharedRef); } }; class FRegistryObject { // 此函数将接受到FMyBaseClass或其子类的TSharedRef或TSharedPtr。 void Register(TSharedRef<FMyBaseClass>); };
不要在构造函数中调用
AsShared
或SharedThis
,共享引用此时并未初始化,将导致崩溃或断言。AsShared
为 public 接口,SharedThis
为 protected 接口(类外调用需要再次封装)7. 辅助函数
7.1 MakeShared
根据输入的参数,创建一个智能指针,相当于std::make_shared。可看下面的解释
再看第二个函数MakeShared,他接收的参数是一堆可变的参数,看注释也说了,等价于std::make_shared,直接在一块内存上构造智能指针和对象本身,好处是对内存就非常友好,减少了一个内存碎片。可以想象一下,如果直接使用TSharedPtr(new T())的形式构造智能指针,其中new T()会先分配一次内存,然后TSharedPtr内部构造ReferenceController又分配了一次内存,这样两块内存不是连续的,耗时也会更高一些,在大量使用智能指针时,性能肯定就不那么好了。这里内部实现就是前面提到的NewIntrusiveReferenceController,这种特殊的构造方式。可以看到内部其实就是直接在Controller自己的内存上,通过placement_new来构造出实际对象,ObjectStorage大小和外部对象一样,但通过模板抹去了对象本身类型,在编译期就计算出大小的一个变量,而整个智能指针的大小就是Controller基类+ObjectStorage的大小,一次分配就完成了构造,这是一个很出色的设计。因此在实践中一定要优先使用这个函数。
TSharedPtr<TestClass> SharedPtr(MakeShared<TestClass>(1,2.f));
7.2 MakeShareable
将一个原始指针转化为共享指针,可以添加自定义deleter
TSharedPtr<TestClass> SharedPtr = MakeShareable( new TestClass(2, 3.f), [](TestClass* deleteObject) { UE_LOG(LogTemp, Warning, TEXT("SharedPtr2:%d\t%f"), deleteObject->a, deleteObject->b); delete deleteObject; } );
在赋值和函数需要返回SharedPtr时很有用。
7.3 MakeUnique
根据输入参数返回一个独占指针,针对于数组和单个对象输入参数解释不一样。由于TUniquePtr的独占特性,不涉及引用计数,内部其实就是简单的new过程
//输入参数作为TestClass的输入参数 TUniquePtr<TestClass> UniquePtr(MakeUnique<TestClass>(5, 6.f)); //输入参数作为数组的大小 TUniquePtr<TestClass[]> UniquePtr4(MakeUnique<TestClass[]>(4));
7.4 MoveTemp
将引用强制转换为右值引用,大多用于移动语义。UE的 std::move,但当传入参数为 rvalue 和 const object 时不会编译(static_assert),因为我们更希望MoveTemp没有作用时被告知。
UniquePtr1 = MoveTemp(UniquePtr2);
存在
MoveTempIfPossible
,没有static_assert。7.5 CleanupPointerArray
Given a TArray of TWeakPtr’s, will remove any invalid pointers.
TArray<TWeakPtr<TestClass>> TestArray; TSharedPtr<TestClass> SharedPtr1{ new TestClass{1,1.f} }; TSharedPtr<TestClass> SharedPtr2{ new TestClass{2,2.f} }; TWeakPtr<TestClass> WeakPtr1 = SharedPtr1; TWeakPtr<TestClass> WeakPtr2 = SharedPtr2; TestArray.Add(WeakPtr1); TestArray.Add(WeakPtr2); //打印2 UE_LOG(LogTemp, Warning, TEXT("%d"), TestArray.Num()); SharedPtr1.Reset(); CleanupPointerArray(TestArray); //打印1 UE_LOG(LogTemp, Warning, TEXT("%d"), TestArray.Num());
7.6 CleanupPointerMap
Given a TMap of TWeakPtr’s, will remove any invalid pointers. Not the most efficient.
TMap的键为TWeakPtr,为什么说Not the most efficient,使用的是WeakPointer.IsValid()来判断,可参考文章末尾注意事项第7点
TMap<TWeakPtr<TestClass>,int> TestMap; TSharedPtr<TestClass> SharedPtr1{ new TestClass{1,1.f} }; TSharedPtr<TestClass> SharedPtr2{ new TestClass{2,2.f} }; TWeakPtr<TestClass> WeakPtr1 = SharedPtr1; TWeakPtr<TestClass> WeakPtr2 = SharedPtr2; TestMap.Add(WeakPtr1,10); TestMap.Add(WeakPtr2,20); //打印2 UE_LOG(LogTemp, Warning, TEXT("%d"), TestMap.Num()); SharedPtr1.Reset(); CleanupPointerMap(TestMap); //打印1 UE_LOG(LogTemp, Warning, TEXT("%d"), TestMap.Num());
8. Cast
8.1 StaticCastSharedPtr与StaticCastSharedRef
Casts a shared pointer/reference of one type to another type. (static_cast) Useful for down-casting.
class BaseTestClass{}; class DerivedTestClass: public BaseTestClass{}; TSharedPtr<BaseTestClass> BasePtr{new DerivedTestClass}; TSharedPtr<DerivedTestClass> DerivedPtr = StaticCastSharedPtr<DerivedTestClass>(BasePtr);
8.2 ConstCastSharedRef与ConstCastSharedPtr
将 const 智能引用或智能指针分别转换为 mutable 智能引用或智能指针,不会重新分配内存,修改会导致const指针/引用发生变化。
TSharedPtr<const int> SharedPtr1{MakeShared<const int>(11)}; TSharedPtr<int> SharedPtr2 = ConstCastSharedPtr<int>(SharedPtr1); (*SharedPtr2) = 22;
注意事项
- 最好不要将原生指针与智能指针进行混用
- 不要将 栈内存 用智能指针进行管理,可能会导致内存被释放两次
- 不要用TUniquePtr与TSharedPtr引用同一个对象
- 不要引用UObject对象
- 避免将数据作为 TSharedRef 或 TSharedPtr 参数传到函数,此操作将因取消引用和引用计数而产生开销。相反,建议将引用对象作为 const & 进行传递。
- 推荐使用MakeShared,防止内存二次分配
- 虽然弱指针提供 IsValid 函数,但是检查 IsValid 无法保证对象在任何时间长度内均可持续有效。线程安全共享指针可能会因另一线程上的活动而随时无效,因此使用线程安全共享指针应尤其注意。Pin 返回的共享指针将使对象在代码将其清除或其超出范围前保持活跃状态,因此 Pin 函数是用于检查的首选方法,此类检查会导致取消引用或访问存储对象。
- 在Set或Map中用作键。弱指针可能会在未通知容器的情况下随时无效,因此共享指针或共享引用更适用于充当键。可安全地将弱指针用作数值。
最后
本文介绍了UE对于非UObject对象的智能指针的用法,没有涉及原理。可以参考这篇文章来探究原理:UE4的智能指针 TSharedPtr。
后面可能会分析智能指针的实现原理,我觉得这是一件很有意思的事情,在UE智能指针实现里面我看到了很多C++八股文的鲜活例子😋。更多UE智能指针使用例子可参考SharedPointerTesting.inl这个文件,跟智能指针在同一个文件夹下面。
参考