Difficulty | Intermediate ⚖️
In this post we explore raw & smart pointers and their usages in Unreal Engine.
A quick refresh on raw pointers
A pointer is essentially just a lightweight way to hold a reference to some memory. The most commonly used are raw pointers which use the following syntax:
MyObject* MyPtr;
Trivia Round - On a 64bit system, how many bits in memory is a pointer to a class that is 16 bytes?
These quite literally “point” to a place in memory, for a simplified explanation - think of memory as a big table that goes from bottom to top with the rows numbered by hexidecimal, aka - the very first row starting at the bottom is 0x0, the next row up is 0x1, then 0x2, and bit like the following:
0x0000000000000003
0x0000000000000002
0x0000000000000001
0x0000000000000000 - NULL
Well all a pointer by itself really does is that it stores one of these hexidecimal numbers, e.g if you have a pointer to a bigger object that takes more than 1 memory address to store, then it would store the base address (starting address of the memory block), seems easy enough?
Now if you answered to the earlier Trivia question 64 bits, you were correct, congratulations. For those that don’t understand - the cost of a pointer is fixed to 64 bits no matter the object, in older times - on a 32bit system this would be 32bits.
So, what about this:
UCLASS()
class UMyObject : public UObject
{
GENERATED_BODY()
public:
int Tuna = 0;
int Crab = 4;
};
UMyObject* MyInstance = NewObject<UMyObject>();
int32 Number = MyInstance->Crab;
What happens we dereference like this if it’s just holding an address in 64bits and nothing else? How the hell does that work?
✨ Using the magic of compile-time type information ✨
When we actually compile the code that uses the ->
operator, it encodes the memory offset (how many bits away from the base address) directly into the instructions themselves, this is what is known as an effective address. If you ever look at the assembly for general code and see lea
- this is load effective address
, and is essentially the way we take the encoded address and load it to make the call on our instance. This process effectively makes up a dereference.
The last thing to mention is about pointer lifetimes. The caveat about raw pointers is that because they are literally just an address, they have no context about the memory they actually point to, and the memory they point to also has no understanding of the fact anything is pointing to it. This is problematic because when you destroy the memory being pointed at you are now unintentionally pointing at something that you didn’t intend, which is known as a dangling pointer, and you are pointing at garbage.
The very problematic part of dangling pointers is that they can be highly deciptive, consider the following scenario:
MyObject* ptr = new MyObject();
delete ptr; // ptr is now dangling
// These checks are NOT reliable
if (ptr != nullptr) { // ptr is still non-null!
if (ptr->someValue) { // Undefined behavior
// ...
}
}
When delete ptr
is called in this snippet, you do not set the pointer to nullptr or clear/zero out the memory in any way, so if we validity check against a nullptr
, it is actually possible provided nothing else changed the memory for that check to return true, and we start calling functions on garbage memory which is undefined behaviour.
So damn, how can we prevent this? Smart pointers!!!
An introduction to smart pointers
Smart Pointers are in laymans terms, a way to do automatic memory management. If we think of a regular (raw) pointer as a “dumb pointer” and understand that it doesn’t really know what it is pointing to, or lifetimes, it’s just a stupid int. A smart pointer is basically a pointer that does understand if it’s pointing at something and it’s lifetimes. There’s a few variants of these, but most smart pointer allocations are generally composed of 2 main things - the control block and the memory. A control block typically is the part that tracks how many pointers are tracking the object (ref counting), this is very important because smart pointers do not rely on the traditional deletion / free methods like normal managed memory. When using smart pointers the memory is managed automatically, it’s worth noting that even raw pointers that have uproperty decorators on them can kind of be considered as a smart pointer because they implicitly inherit a behaviour similar to a TStrongObjectPtr (more about these later).
I would personally say there is 2 main categories of smart pointer in Unreal, “object ptrs” and “not object ptrs”. These function quite differently and despite having quite similar names, it’s very important to know the distinction between when you should use an object ptr or not. When we talk about objects in an Unreal sense, we are typically talking about UObject which is the main object type in Unreal, any time you are pointing to a UObject you should expect to be using object ptrs eg (TObjectPtr, TWeakObjectPtr, TStrongObjectPtr). Alternatively, if the memory you want to point at is just plain ol’ data or a vanilla C++ struct or class, then it’s expected to use a vanilla smart ptr eg (TWeakPtr, TSharedPtr, TUniquePtr).
A key difference in how object ptrs work is that they are typically garbage collection based, whereas vanilla smart pointers (non object ptrs) are RAII based. In RAII instead of using new
and delete
keywords, new is called for you behind the scenes when you initialize and allocate your first pointer. Then delete is also called for you when all associated smart pointers are invalidated / cleared, when there is nothing pointing at your memory, it’s automatically destroyed.
Here’s a cheat sheet for the various properties of each pointer!
Pointer Type | GC-Aware | Auto Nullifies | Ref Counting | Use Case |
---|---|---|---|---|
TObjectPtr<T> |
✅ Yes | ❌ No | ❌ No | Member pointers for UObjects |
TWeakObjectPtr<T> |
✅ Yes | ✅ Yes | ❌ No | Weak references to UObjects, avoids dangling pointers |
TStrongObjectPtr<T> |
✅ Yes | ✅ Yes | ✅ Yes | Strong ownership of UObjects, prevents GC deletion |
TSharedPtr<T> |
❌ No | ❌ No | ✅ Yes | Shared ownership, non-UObject types |
TUniquePtr<T> |
❌ No | ❌ No | ❌ No | Unique ownership, non-UObject types |
TWeakPtr<T> |
❌ No | ✅ Yes | ❌ No | Weak reference to a TSharedPtr -managed object |
TObjectPtr
The first relevant smart pointer type - isn’t really a smart pointer as such, but is an Unreal flavoured evolution of the raw pointer. In Unreal Engine we use garbage collection for memory management, contrary to the typical expectations from C++. The garbage collector essentially scans for unused memory aka “garbage” which is no longer pointed at and has reached the end of it’s lifetime. This works by bodging a bit of a hack on the top of a pointer via reflection, raw pointer with the UPROPERTY
decorator are discoverable by the garbage collector, which is why having a UObject* with no uproperty decorator on it has always been a BIG nono - because it essentially meant that the garbage collector couldn’t properly discover your pointer to see if said memory should be alive, and it would proceed to just kill it, but now your raw pointer is pointing at garbage, which may or may not be destroyed - undefined behaviour galore. Fast forward to Unreal 5, raw pointers as members got deprecated, and now we use TObjectPtr<T>
which allows us some super powers with incremental GC.
A very important thing to note about TObjectPtr
is that it is not ref counted. Unreal GC has a mechanism that functions to achieve the same result as ref counting however the memory allocation itself does not use ref counting.
Shared Pointers (TSharedPtr, TStrongObjectPtr)
Shared Pointer is the staple pointer that is typically used whenever you have a pointer to an object that doesn’t need performant or intricate memory management. They are a super simple way to basically allocate memory somewhere. You don’t particularly care where, just throw it somewhere! On the heap you go! Now great we have this bit of memory on the heap, we don’t really want to have to remember to delete that when we are done, let the computer do it for us. We use ref counting to track how many shared and weak pointers are referencing our memory. When our reference counter hits 0 because all of the shared pointers ceased to exist, the memory will automatically be freed.
When to use TSharedPtr - When you have a non-UObject based type that you need to allocate on the heap, but you don’t want the burden of managing. If you have a shared pointer to something, unless it has been deleted externally, it’s usually safe to assume you can dereference the memory, as such, you don’t want to really use them when you are in a situation where the lifetimes are ambiguous and you don’t explicitly want to force said memory to keep existing. Avoid using shared pointers when you want to allocate on the stack.
When to use TStrongObjectPtr - You probably noticed that TSharedPtr works actually very similarly to TObjectPtr! While this is pretty nice for non-UObjects, when we are referencing UObjects if you were paying attention you will remember we need UPROPERTY() above our TObjectPtr. But wait… How can you do that if you’re trying to hold a pointer to a UObject in a class that isn’t reflected, we don’t have reflection there. Well, that’s where TStrongObjectPtr comes in clutch, you don’t need to have UPROPERTY above a TStrongObjectPtr and it will exhibit most of the same behaviours as TObjectPtr does. Avoid TStrongObjectPtr if a regular TObjectPtr will suffice as TStrongObjectPtr is specifically designed to handle non-reflected scenarios in UObject management.
Weak Pointers (TWeakObjectPtr, TWeakPtr)
The next relevant smart pointer type we need to talk about are weak pointers! WeakObjectPtr and WeakPtr are not typically used for the exact same purposes, but they do share common behaviours. The overlap typically comes with the usage of validity checking, a nice part of weak pointers is their ability to efficiently check if the memory being pointed to is valid, not dangling, and safe for access. The other thing that they share is automatic nullification of the pointer when the referenced object or memory is destroyed.
When to use TWeakObjectPtr - When you need a pointer to a UObject that doesn’t stop it from being garbage collected OR when you cannot guarantee the validity of said object because it has an opportunity to dangle. Note that Unreal Engine will have trouble destroying worlds if something like an actor is held from GC while trying to destroy it, therefore if you had a system outside of your world referencing an actor (maybe a killcam system), this would be a perfect example case for a TWeakObjectPtr. Avoid use of TWeakObjectPtr when you have a UObject that you know is always going to be valid, in this case you would want a TObjectPtr
or a TStrongObjectPtr
. You also don’t want TWeakObjectPtr if you actually need the garbage collector to be forced not to destroy your object.
When to use TWeakPtr - When you need a pointer to a vanilla class, struct or type that doesn’t prevent automatic deletion. It is intended to be used alongside TSharedPtr and requires at least 1 active TSharedPtr to keep the memory alive, otherwise all of your TWeakPtrs are going to automatically nullify and be invalidated.
MakeShared vs MakeShareable
When creating a shared pointer allocation, you will see that MakeShared()
or MakeShareable()
are used, typically interchangably and seemingly for the exact same purpose. However it is important to understand these do not do the same thing! They control how your allocation happens in a very important way and it is the difference between doing 1 and 2 allocations. MakeShareable()
is my preferred default, this will do 2 allocations, seperating the control block and the memory itself into 2 different parts. An important thing to understand here is that while any shared or weak pointer exists to your memory, you must keep the control block around. This is actually a big deal because if you allocate these together, it means that your actual pointer memory cannot be freed while there is any weak pointer active to your memory! As you can probably guess, MakeShared()
does a single allocation, putting the control block and memory into the same block.
When to use MakeShared() - Use it whenever you need fast shared pointer allocations over memory efficiency, or when you don’t expect weak pointers to exist for the memory in question, totally fine if you’re only going to be using shared pointers. Avoid it when you’re going to be using WeakPtr.
When to use MakeShareable() - Use it pretty much defaultly, assume you are going to need the flexibility until you know you’re not. For most cases this isn’t going to be a performance problem and in the cases where it is, you will likely swap to MakeShared, profile it and then proceed to not use MakeShared either because if you’re truly cranking performance you likely won’t want to be using smart pointers anyway.
Unique Pointers (TUniquePtr)
A more niche one is unique pointers. These are pretty underused because they cover a very specific purpose. Unique pointers are used when you want exclusive ownership, one king to rule them all. They have no reference counting or shared ownership so typically have lower overhead. It’s a lighter and more efficient choice when you know there’s only one owner of the memory at any point in time. However, it’s usually quite a niche case that you are worried about both performance and shared pointer overhead, most of the time people are just going to use a shared pointer and call it a day, because frankly the overhead isn’t bad at all unless you are doing something incredibly performance critical, in which case you probably aren’t going to be using smart pointers anyway.
When to use TUniquePtr - When you need a pointer to a non-UObject based type that gets allocated on the heap, but does not need shared ownership.
Custom Deleters
So we established earlier that smart pointers automatically handle our deletion on our behalf right? But hang on a minute! That’s just going to delete our memory!! What if we don’t want this, what if it needed to be deleted in a specific way, say for example, we want to add to a freelist when our object is deleted, but maintain the automagic behaviour we like - enter custom deleters.
One thing to be aware of when using custom deleters is that they are feature typically used for C apis because you are often required to call free() inside libraries themselves as they may have their own allocation strategies. You can use them in some very very cool ways, however it’s worth noting that many people may or may not want to beat you to death for it in code reviews - Use at your own peril.
Here is a cool example of how we could abuse custom deleters to automatically fill a freelist and free our item in a linear dataset when all handles to it are destroyed - as an example you could use this for sparsely referencing loaded chunks on a voxel world, loading a chunk gives you a handle, and when there’s no longer any handles left to your chunk, they can automatically unload themselves.
struct FHandleData
{
int32 DataIndex = 0;
// Some other information required to access your data.
};
using FHandle = TSharedPtr<FHandleData>;
TArray<int32> Dataset;
TArray<int32> FreeList;
auto HandleDeleter = [&FreeList, &Dataset](void* ObjectToDelete)
{
FHandleData* HandleData = reinterpret_cast<FHandleData*>(ObjectToDelete);
FreeList.Add(HandleData->DataIndex);
Dataset[DataIndex] = INDEX_NONE;
FMemory::Free(ObjectToDelete);
};
FHandle NewHandle = MakeShareable(new FHandleData(), MoveTemp(HandleDeleter));
NewHandle->DataIndex = Dataset.Add(5092);