Unreal 各種指標型別是怎麼回事

不三週助發表於2023-02-19

引言

讀完本篇文章,你會了解為何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 開啟(√)

[圖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,如果不想加的話,那麼使用弱指標的方式即可。

相關推薦參考

相關文章