Safety of using `TObjectPtr` as keys in `TSet` / `TMap` – hash stability after GC & real-world advantages?

Hi!

I’m experimenting with containers that cache up to ~100 actors whose lifetimes are not controlled by the owner of the container.

`// simplified example
UPROPERTY()
TSet<TObjectPtr> Tracked;

// elsewhere
Tracked.Add(SomeActor);

SomeActor->Destroy(); // or gets GC-ed later`---

1. Hash stability / look-up correctness

* When `SomeActor` is destroyed and collected, `TObjectPtr` starts returning `nullptr`.

* Does the *stored* key’s hash value change?

* Will `Tracked.Contains(SomeActor)` still enter the correct bucket?

* Could two “dead” keys collide (all hashing to `0`) and corrupt the set?

* Is `TObjectPtr` any safer than raw pointers as a *key*?

2. Performance / memory vs. `TArray`

* With only ~100 elements, is there any measurable benefit in using a hash container over a linear `TArray` + `FindByPredicate`?

* Are there engine heuristics that make `TSet`/`TMap` preferable even for such small counts?

3. Recommended best practices

* Does Epic suggest avoiding `TObjectPtr` as keys altogether?

* Should keys always be something immutable like `FObjectKey` or a GUID, and object pointers live as *values*, not keys?

* Any production patterns you can share for “cache of actors that may disappear at any time”?

4. TWeakObjectPtr

What is the official recommendation regarding using `TWeakObjectPtr` as a key in `TMap` or `TSet`?

We’ve observed that `TWeakObjectPtr`'s `operator==` treats any two invalid pointers as equal, even if their serial/index values differ. This can potentially lead to broken container invariants after GC, especially if two distinct weak pointers become invalid and end up treated as duplicates due to hash collisions.

Should we completely avoid using `TWeakObjectPtr` as a key?

---

I’ve read forum threads that hint at hash inconsistencies but couldn’t find an authoritative answer in the docs or code comments.

Thanks in advance for clarifying!

— Kirill Mintsev

Hi Kirill,

Let me start with your last point:

> What is the official recommendation regarding using `TWeakObjectPtr` as a key in `TMap` or `TSet`?

It doesn’t matter whether the key type is TObjectPtr<UThing>, UThing* (when a UPROPERTY) or TWeakObjectPtr (regardless of it’s a UPROPERTY). The smart pointers are no more magical than raw pointers when it comes to invalidation of set/map keys. If the key’s pointer value ends up changing outside of the knowledge of the set/map, the container doesn’t know about it and ‘all bets are off’.

> * Does the *stored* key’s hash value change?

When it’s a UPROPERTY, yes, the GC will null it in-place in the container and the container won’t know that it’s happened. The hash will become zero, so you’ll have an element in the wrong bucket (unless the hash of the original pointer also happened to go into bucket 0). Same as when it’s a TWeakObjectPtr - it effectively changes itself to a ‘logical null’ and the container still doesn’t know about it.

> Will `Tracked.Contains(SomeActor)` still enter the correct bucket?

If SomeActor is a (dangling) non-null pointer to a GC’d object, it will look in the correct bucket, but it won’t compare equal with the null that replaced the original pointer value, so .Contains() will still return false.

> Could two “dead” keys collide (all hashing to `0`) and corrupt the set?

Technically, the set is already corrupted by this time, as its invariants have been violated. That’s not to say that the container can’t be recoverable back into a valid state, but until that happens, you can’t rely on sensible behaviour. Two different dead keys will not collide, but they will cause you to have a map with multiple of the same key, which shouldn’t be possible except in a TMultiMap, but also because if you’re only inserting non-null values, you shouldn’t find nulls either. Lookups for the original pointer values will fail, lookups for nulls may fail, depending on whether the nulls hash to the same bucket. Lookup behaviour may also change after a rehash, as those nulls *will* then get hashed as null and put in the right bucket. The only way to restore the container to a valid state is to iterate over it with a TIterator and remove elements with It.RemoveCurrent(); whenever you find a null.

> With only ~100 elements, is there any measurable benefit in using a hash container over a linear `TArray` + `FindByPredicate`?

> Are there engine heuristics that make `TSet`/`TMap` preferable even for such small counts?

In fact, our TSortedMap is an array-backed container with a TMap-like API with binary searching, and as a rule-of-thumb, we usually suggest to use that if your typical container size is likely to be under 100 elements. Not that you can use that, because TSortedMap currently isn’t a supported property type. A TArray with FindByPredicate would be linear search rather than a binary search - I wouldn’t like to say if the performance would be hugely different from TSortedMap but I think it’s still a reasonable option. And you can go for binary searching if you are willing to maintain the array in sorted order. I would suggest you do your own timings here, as it will depend on your balance of inserts vs. lookups.

> Does Epic suggest avoiding `TObjectPtr` as keys altogether?

Generally, yes, for all the problem types mentioned above.

> Should keys always be something immutable like `FObjectKey` or a GUID, and object pointers live as *values*, not keys?

If you don’t need your container to be a UPROPERTY and have only made it so for GC reasons, FObjectKey and TObjectKey are intended to be usable as set/map keys with stable hashes even in the face of garbage collection. They are not valid UStructs, so can’t be used as a UPROPERTY. Of course, if you give up UPROPERTY then TSortedMap is also back on the table. Object pointers can be map values, sure - the container won’t be invalidated if they become null.

> Any production patterns you can share for “cache of actors that may disappear at any time”?

TMap<TObjectKey<…>, …> is not an uncommon pattern in the engine, but some specific examples can be found in FObjectCacheContext. Not actors, but UObjects at least.

Hope this helps,

Steve

Please correct me if I’m wrong, but my understanding was that for TWeakObjectPtr keys, there is also TWeakObjectPtrMapKeyFuncs/TWeakObjectPtrSetKeyFuncs that allow them to be used safely in TMap/TSet respectively. You can do something like so:

`template <class Key, class Value>
using TWeakPtrKeyMap = TMap<TWeakObjectPtr, Value, FDefaultSetAllocator, TWeakObjectPtrMapKeyFuncs<TWeakObjectPtr, Value>>;

TWeakPtrKeyMap<AActor, FString> MyMap;`

This can’t be a UPROPERTY of course either.

Hi Steve,

Thank you so much for the detailed and insightful explanation — I really appreciate the time you took to go through each point so thoroughly. This clarifies a lot for me.

With appreciation,

Kirill

That’s true, this is another option.

Steve