Unreal 各种指针类型是怎么回事

时间:2023-02-20 07:08:50

引言

读完本篇文章,你会了解为何UE中C++作为其开发语言,使用的指针,为何各式各样。
你需要对UE有所了解,如果不了解也没关系,也可以看下这篇文章,就当了解一下最复杂的应用的系统指针设计是如何。
可以肉眼可见,类对象存在还是被释放了。

类型

我这边给出的是自己个人对指针种类分类的看法,主要是结合项目使用情况,大致得出下列类型。
graph LR C{指针} C --> D[原生C++裸指针] C --> E[原生C++共享指针] C --> F[原生C++弱指针] C --> G[UObject裸指针] C --> H[UObject带UProperty指针] C --> Y[UObject弱指针]

工具

  • 将UE中EditorPreference->Show Frame Rate and Memory 打开(√)

Unreal 各种指针类型是怎么回事[图1]
可以通过观察上图内存变化,肉眼可见对象是否彻底释放。(其实或者看Log,主要是构造函数和析构函数)

  • 自定义FCustomDefinedClass,不继承任何基类,即是纯原生C++类。
//自定义原生C++类

class FCustomDefinedClass
{
public:
	FCustomDefinedClass()
	{
		Arr.AddDefaulted(100*1024*1024); //为了测试便于观察对比,申请内存
		UE_LOG(LogTemp, Log, TEXT("FCustomDefinedClass() Start"));
	}

	~FCustomDefinedClass()
	{
		Arr.Reset();//为了测试方便,释放内存
		UE_LOG(LogTemp, Log, TEXT("~FCustomDefinedClass() Stop"));
	}

	void PrintArr()
	{
		UE_LOG(LogTemp, Log, TEXT("FCustomDefinedClass PrintArr"));
	}

	TArray<bool> Arr;
};

UCLASS()
class UCustomDefinedObject :public UObject
{

	GENERATED_BODY()

public:

	UCustomDefinedObject(const class FObjectInitializer& ObjectInitializer) {
		Arr.AddDefaulted(100 * 1024 * 1024); //为了测试便于观察对比,申请内存
		UE_LOG(LogTemp, Log, TEXT("UCustomDefinedObject() Start"));
	};

	~UCustomDefinedObject()
	{
		Arr.Reset();//为了测试方便,释放内存
		UE_LOG(LogTemp, Log, TEXT("~UCustomDefinedObject() Stop"));
	}

	void PrintArr()
	{
		UE_LOG(LogTemp, Log, TEXT("UCustomDefinedObject PrintArr"));
	}

	TArray<bool> Arr;
};

构造函数中我们申请100MB的内存,在析构函数中释放这100MB的对象。
在代码中New出一个该类对象,内存就会增大100M,该类被析构,就会释放,于是肉眼可见的对象是否存活,实现了。

  • 强制开启GC指令,控制GC的开启时机可以方便我们快速测验。
    gc.ForceCollectGarbageEveryFrame 1

分析

一步一步来,从最简单的开始分析。

1.原生C++裸指针
其实这个比较简单,我new一个,之后我必须手动释放。代码如下

    //UE中观察引擎内存显示(类似图1)
    // Mem:1309MB
    FCustomDefinedClass* InCustomDefinedObject = new FCustomDefinedClass();
    // Mem:1407MB
    delete InCustomDefinedObject;
    InCustomDefinedObject = nullptr;
    // Mem:1299MB

(大约都是100MB的落差,符合预期,有点误差,可以忽略,FCustomDefinedClass类的作用完成,类对象肉眼可见是否存在实现)

2.原生C++共享指针
上述代码如果不写或者漏调 delete InCustomDefinedObject,观察内存显示,即使我停止(Play)游戏,数目都没有减少,再次Play启动游戏 New该类,再停止Play,会发现内存一直在增加,这就是传说的内存泄漏。 非常严重。我只是没调这个析构,忘记调了(对象那么多,每个都要delete,肯定忘记),可是每个对象都需要手动这么写,也太累了。 于是C++原生的智能指针出现了。

MakeShareable<FCustomDefinedClass> InCustomShareObject = MakeShareable<FCustomDefinedClass>(new FCustomDefinedClass());
InCustomShareObject = nullptr;

再次观察内存情况,内存可以正常释放。

  • InCustomShareObject置为nullPtr
  • InCustomShareObject置为nullPtr变量超出作用域
  • 本质就是没有引用计数了,会立刻自动执行析构函数,释放占有的内存。

关于共享指针的原理,可以参考:手把手带你实现一个智能指针
3.原生C++弱指针
使用共享指针的主要原因是避免手动管理指针释放资源。但是,在某些情况下共享指针不能实现预期的行为:
一种情况是循环引用。如果两个对象使用共享指针相互引用,并且不存在对这些对象的其他引用,若要释放这些对象及其关联的资源,则共享指针不会释放数据,因为每个对象的引用计数仍为1。在这种情况下,可能想使用普通的指针,但是这样做需要手动管理相关资源的释放。
另一种情况是当明确想要共享但不拥有对象。这种情况下引用的生存期超过了它所引用的对象的生命周期。如果使用共享指针则其将永远不会释放对象。如果使用普通指针则可能出现指针所引用的对象不再有效,这会带来访问已释放数据的风险。
对于这两种情况都可以使用弱指针指针处理。弱指针是共享指针的辅助类,弱指针需要共享指针才能创建。

上述我们知道共享指针是如果有引用计数,就不会被释放,那么如果我只是想用一个对象,但是又不想对他造成影响,就是不想影响他的计数,不想影响他的生命周期。换而言之就是共享指针那边该干嘛就干嘛,我这边WeakPtr这边不影响他。只是说他那边没了,我这边也要没了,他那边还在,我这边就还在。
于是弱指针就来了。

void ATestObjectActorManager::TestCallGenerate()
{
	 const TSharedPtr<FCustomDefinedClass> WeakSharePtr = MakeShareable<FCustomDefinedClass>(new FCustomDefinedClass());
	 InCustomWeakObject = WeakSharePtr;
}
//WeakSharePtr 在这个函数执行完,因为是临时变量,会被干掉,引用计数为0,释放内存了。

void ATestObjectActorManager::TestCallDestory()
{
	if (InCustomWeakObject.IsValid()) //执行到这的时候InCustomWeakObject已经invalid了,为false了。
	{
		// ....
	}
}

(共享指针&弱指针用法,都需要IsValid来预先判断)

4.UObject裸指针
终于到了UE这边了,因为UE考虑到C++的指针释放内存啥的是个麻烦的事,C++原生虽然有自己的智能指针,但是作为游戏,有一些觉得C++原生做的不好的(具体我也不知道哪里不好)。自己搞的,才是适合自己的,适合游戏的,于是UE 让UObject(组成UE世界的最小单元)就附带了垃圾回收的功能
案例一

void ATestObjectActorManager::TestCallGenerate()
{
	UCustomDefinedObject* TempDefinedObj =  NewObject<UCustomDefinedObject>();
}

该函数执行完,因为是临时变量,做得事跟上述共享指针类似得事,引用计数为0,但是观察内存情况,尝试执行3次,每次都在不断增长1
0MB内存,涨了300MB
我们这个时候在输入强制GC指令:gc.ForceCollectGarbageEveryFrame 1
之后会发现上涨得300MB都被释放了。

void ATestObjectActorManager::TestCallGenerate()
{
	 TempDefinedObj =  NewObject<UCustomDefinedObject>();
}

因为没有UProperty,执行GC,该因为没有引用,所以被释放且指针没有置nullPtr,就是传说“野指针”了
小结:继承自UObject得裸指针在没有引用计数后,可能算是“泄漏”,但是只要有UE得垃圾回收机制执行,这些所谓“泄漏”得内存还是会被释放。

5.UObject带UProperty指针
因为有UPROPERTY,引用关系计算了,

void ATestObjectActorManager::TestCallGenerate()
{
	 TempDefinedObj =  NewObject<UCustomDefinedObject>();
}

这个时候使用ForceGC指令,内存是不会变化的。
这个时候我给所在对象使用MarkPendingKill,则内存被释放掉。
加了的话,如果所引用的UObject被MarkPendingKill,则该Uobject也会被强制回收。

小结:加了UProperty,算这个UObject指针加入计数了,不然就会被当作没有计数被释放且野指针。

6.UObject弱指针
我们前面已经说过了原生C++ 有共享指针,弱指针。当然UE这边有自己的智能指针Uibject,但是没有弱指针,对于继承于UObject的指针,可以使用UObject的弱指针使用方式。

    UCustomDefinedObject* InObject = NewObject<UCustomDefinedObject>();
    TWeakObjectPtr<UObject> ObjectWithWeak(InObject);

也是跟上述原生的C++弱指针的使用方式类似。这里因为UObject的指针本身就自带共享功能,所以这边直接赋值即可。

总结

来源:
C++里有原生指针,可是真的太麻烦,太危险,不好使,所以出了共享指针,自动帮你管理释放,但是共享指针因为计数原理,还有一些副作用弊端,还有需求就是只是单纯的想使用并不想计入引用,于是出了弱指针。在游戏,就是UE这边因为性能等的综合考虑弄了自己的一套自动管理释放对象的系统,就是UObject系统,还有专门针对UObject对象使用的弱指针。
应用:
首先想直接使用原生C++裸指针,肯定是不建议的, 太危险,因为忘记delete后果非常严重。
如果你的类不是继承自UObject,不需要UObject提供的反射等其他复杂功能,真的很简单的类对象的话,那么就使用原生C++的共享指针存储,如果在其他地方需要对共享指针有个引用,但是又不想影响其计数,就使用弱指针。
对于继承自UObject的指针,非常不推荐裸指针的方式,就是不加UPROPERTY, 一定要加UPROPERTY,如果不想加的话,那么使用弱指针的方式即可。

相关推荐参考