Unreal 浅谈TWeakObjectPtr

Unreal 浅谈TWeakObjectPtr

【USparkle专栏】如果你深怀绝技,爱“搞点研究”,乐于分享也博采众长,我们期待你的加入,让智慧的火花碰撞交织,让知识的传递生生不息!


前言

在Unreal的开发过程中,正确的引用和管理UObject是十分重要的,尤其Unreal有着它自己的UObject的GC机制,这使得对UObject的有效引用和管理变得尤为关键。不正确的引用UObject将造成循环引用或者影响该UObject的生命周期,这都是不希望看到的。所以Unreal通过TWeakObjectPtr提供一种弱引用来解决这个问题,在本篇文章中将详细探讨TWeakObjectPtr的工作原理。

TWeakObjectPtr是什么?

TWeakObjectPtr是UE4中的一个C++模板类,用于存储对UObject以及其派生类的弱引用。在UE4中UObject是所有游戏对象的基类,包括角色、场景组件、蓝图等,并且UE提供了一套GC机制来管理UObject的生命周期。弱引用意味着TWeakObjectPtr不会阻止其引用的UObject被GC销毁。这在某些情况下是非常有用的,比如当你需要访问一个UObject时,但不希望因为你的对UObject引用而导致永远不会被销毁。

示例代码如下所示,这里引用一个AActor*指针并且使用UPROPERTY将其纳入到UE的GC机制,这种写法相当对该AActor就添加了一次引用,意味着该Actor将不会被GC。当然如果使用TWeakObjectPtr来包装对应AActor,这样可以使用该AActor,但是并不会阻止其GC。

UPROPERTY()
AActor* A;

TWeakObjectPtr<AActor> A;
A = GetXXXActor();

当然既然TWeakObjectPtr不会影响对应UObject的GC流程,那么使用时也需要注意其有效性。使用前当然需要检验当前UObjecct的可用性,TWeakObjectPtr提供了IsValid方法用于判断引用的UObject是否可用,Get方法则会返回一个UObject指针。具体代码如下所示:

AMyActor* Actor
TWeakObjectPtr<AMyActor> ActorReference = Actor;
.......

if (ActorReference.IsValid())
{
    // 使用Get()方法获取对象指针
    AMyActor* ValidActor = ActorReference.Get();
    // 在有效的Actor上执行操作
    ValidActor->SomeMethod();
}
else
{
    // TWeakObjectPtr无效,可能是因为对象已被销毁
    UE_LOG(LogTemp, Warning, TEXT("Actor reference is invalid!"));
}

TWeakObjectPtr实现

接下来看看TWeakObjectPtr的实现。TWeakObjectPtr的声明如下所示:

template<class T=UObject, class TWeakObjectPtrBase=FWeakObjectPtr>
struct TWeakObjectPtr;

template<class T, class TWeakObjectPtrBase>
struct TWeakObjectPtr : private TWeakObjectPtrBase

可以看出TWeakObjectPtr是一个模板类,它继承自TWeakObjectPtrBase(一般默认为FWeakObjectPtr)。TWeakObjectPtr的模板参数T表示要引用的UObject派生类的类型,而模板参数TWeakObjectPtrBase表示实际弱引用实现的类型,也就是说TWeakObjectPtr实际上是FWeakObjectPtr的模版化包装。在Unreal中有很多的例子,当你看到TXXX的模板类时,应该想到后面有一个FXXX类在默默支持它。首先来看看隐藏在幕后的FWeakObjectPtr吧!

需要注意的一点,Unreal的实现使用的是Private继承。可能平时开发中使用Private继承的场景相对较少,但是对于各种基础库中使用Private继承还是不少的。略微揣摩一下Unreal使用Private继承的意图所在,先列一下Private继承可能会发挥到的作用:

  1. Private继承表面是继承,其实本质是一种更加简便达到组合的方式,子类和父类并不是Is-a的关系。完全避免类型兼容原则(子类可被认为是父类),可显示阻止不合理的类型转化(编译期暴露问题)。
  2. 使用继承便可以利用上空基类优化(EBO)以减少内存占用。
  3. 不希望派生类的子类直接使用基类的任何变量和方法(可以用using改变可见性)。
  4. 派生类可以覆盖基类的虚函数,但是不将这些函数暴露给外部。

第1、2点应该是Unreal使用Private继承来实现的原因,首先是符合语义,本质上TWeakObjectPtr是FWeakObjectPtr的一层模板包装,这也就是一种组合的实践方式;其二就是能够享受空基类优化来减少内存占用。对于第3、4点来说,一是FWeakObjectPtr并没有虚函数,二是一般来说正常开发流程中不会再去继承TWeakObjectPtr了。所以在这里第3、4点效果并无体现。

接下来看看FWeakObjectPtr的具体实现。具体的文件路径:\Engine\Source\Runtime\CoreUObject\Public\UObject\WeakObjectPtr.h

先列出一些重要的代码,更多细节请看源码:

struct FWeakObjectPtr
{
public: 
    FORCEINLINE FWeakObjectPtr()
    {
        Reset();
    }
    FORCEINLINE void Reset()
    {
        ObjectIndex = INDEX_NONE;
        ObjectSerialNumber = 0;
    }

    FWeakObjectPtr(const FWeakObjectPtr& Other) = default;

    FWeakObjectPtr& operator=(const FWeakObjectPtr& Other) = default;

    COREUOBJECT_API class UObject* Get(bool bEvenIfPendingKill) const;

    COREUOBJECT_API class UObject* Get(/*bool bEvenIfPendingKill = false*/) const;

    COREUOBJECT_API bool IsValid(bool bEvenIfPendingKill, bool bThreadsafeTest = false) const;

    COREUOBJECT_API bool IsValid(/*bool bEvenIfPendingKill = false, bool bThreadsafeTest = false*/) const;
private:
    FORCEINLINE FUObjectItem* Internal_GetObjectItem() const;

    FORCEINLINE_DEBUGGABLE bool Internal_IsValid(bool bEvenIfPendingKill, bool bThreadsafeTest) const;

    FORCEINLINE_DEBUGGABLE UObject* Internal_Get(bool bEvenIfPendingKill) const;

    int32       ObjectIndex;
    int32       ObjectSerialNumber;
}

Unreal的UObject管理
这里需要额外关注的就是ObjectIndex和ObjectSerialNumber字段,你可能会疑惑为什么只需要两个int32类型就可以了,甚至在FWeakObjectPtr中都不需要存储任何的UObject。这和Unreal对于UObject管理有关。首先在Unreal中所有的UObject对象都会放到FUObjectArray来管理,在UObjectBase构造时会调用AddObject,再将AddObject调用AllcateUObjectIndex函数的UObject放到一个FUObjectArray对象中,如下所示:

并且在AllocateUObjectIndex中会构造一个FUObjectItem对象并会将FUObjectItem和UObject两者一一对应,同时广播UObjectCreate事件,如下图所示:

FUObjectItem对象会保存指向对应UObject的指针,UObject持有InternalIndex以便后续直接拿到对应的FUObjectItem。因为FUObjectItem持有UObject的指针(双向奔赴了),所以在UObject销毁时广播UObjectDelete事件,调用FreeObjectIndex后对应的FUObjectItem也会被销毁。

看到这里就可以知道Unreal并不直接管理具体的UObject,而是通过FUObjectItem去获取对应的UObject,并且这个FUObjectItem相当的轻量,所以在FUObjectArray持有一个FChunkFixedUObjectArray来储存所有的FUObjectItem。它们之间的关系可以通过下面这张图来解释:

还有FUObjectHashTables也是管理全局UObject对象,但是它是维护UObject名字(Name)到对象指针的映射,父子(Outer)对象之间的映射等,方便对UObject进行查找等操作和FWeakObjectPtr实现关系不大,所以在这里就不展开了。

现在已经知道ObjectIndex是被用于获取指定的UObject,那么ObjectSerialNumber是用来干什么的呢?还得补充一个知识点,那就是ObjAvailableList其实在AllocateUObjectIndex的实现是有用到的。获取对应的Index时会优先从ObjAvailableList获取一个可用的索引,无需遍历寻找。如果ObjAvailableList没有,则FChunkFixedUObjectArray需要扩容。

当然在FreeObjectIndex调用时,会向ObjAvailableList放入新的索引。

这就代表InternalIndex是循环使用,这样索引缓存机制会带来一个问题,那就是Unreal只会保证在同一时刻的所有的UObject的InternalIndex是不同的,但是在不同的时间下,不同的UObject会持有相同的InternalIndex。这对于FWeakObjectPtr来说显然是有问题的,因为可能会有FWeakObjectPtr当前指向的UObject并不是当初的UObject,但是它们的InternalIndex还是相同的,所以在这里多加了一个ObjectSerialNumber来确保能找到唯一的UObject。

ObjectSerialNumber是一个递增的数字,每当通过UObject去创建一个FWeakObjectPtr时都会递增,也就是ObjectSerialNumber并不会重复(当然你可能会担心int32会不会爆掉,但是你得先创建21亿个FWeakObjectPtr,如果真出现这种问题,你应该优先考虑为什么会有21亿个FWeakObjectPtr,而不是担心它会爆掉),同时这里会对FUObjectItem中的SerialNumber做比较。具体代码如下所示:

也可以从FWeakObjectPtr的一些实现,看出ObjectSerialNumber的意义所在,比如==和!=的运算符重载函数,这里都对ObjectIndex和ObjectSerialNumber进行了双重验证,才能真正确认指向的UObject是当初哪个。

到这里ObjectIndex和ObjectSerialNumber字段的含义就解释完毕。

回到FWeakObjectPtr
可以继续看FWeakObjectPtr的一些重要的实现。首先来看一下针对UObject的赋值构造函数,这是通过FUObjectArray的ObjectToIndex函数获取到对应的Index,并且通过AllocateSerialNumber函数生成唯一的ObjectSerialNumber,最后还调用了SerialNumbersMatch函数以确保UObject唯一性。

FORCEINLINE_DEBUGGABLE bool SerialNumbersMatch() const
{
    checkSlow(ObjectSerialNumber > FUObjectArray::START_SERIAL_NUMBER && ObjectIndex >= 0); // otherwise this is a corrupted weak pointer
    int32 ActualSerialNumber = GUObjectArray.GetSerialNumber(ObjectIndex);
    checkSlow(!ActualSerialNumber || ActualSerialNumber >= ObjectSerialNumber); // serial numbers should never shrink
    return ActualSerialNumber == ObjectSerialNumber;
}
void FWeakObjectPtr::operator=(const class UObject *Object)
{
    if (Object // && UObjectInitialized() we might need this at some point, but it is a speed hit we would prefer to avoid
        )
    {
        ObjectIndex = GUObjectArray.ObjectToIndex((UObjectBase*)Object);
        ObjectSerialNumber = GUObjectArray.AllocateSerialNumber(ObjectIndex);
        checkSlow(SerialNumbersMatch());
    }
    else
    {
        Reset();
    }
}

接下来就是FWeakObjectPtr的一些常用方法的实现,比如Get和IsVaild的实现都是通过以下函数实现的。主要就是通过ObjectIndex和ObjectSerialNumber判断当前的FWeakObjectPtr是否可用的操作。当调用Get,如果ObjectItem可用则直接返回UObject指针否则返回nullptr。

FORCEINLINE FUObjectItem* Internal_GetObjectItem() const
{
    if (ObjectSerialNumber == 0)
    {
        checkSlow(ObjectIndex == 0 || ObjectIndex == -1); // otherwise this is a corrupted weak pointer
        return nullptr;
    }
    if (ObjectIndex < 0)
    {
        return nullptr;
    }
    FUObjectItem* const ObjectItem = GUObjectArray.IndexToObject(ObjectIndex);
    if (!ObjectItem)
    {
        return nullptr;
    }
    if (!SerialNumbersMatch(ObjectItem))
    {
        return nullptr;
    }
    return ObjectItem;
}

FORCEINLINE_DEBUGGABLE bool Internal_IsValid(bool bEvenIfPendingKill, bool bThreadsafeTest) const
{
    FUObjectItem* const ObjectItem = Internal_GetObjectItem();
    if (bThreadsafeTest)
    {
        return (ObjectItem != nullptr);
    }
    else
    {
        return (ObjectItem != nullptr) && GUObjectArray.IsValid(ObjectItem, bEvenIfPendingKill);
    }
}

FORCEINLINE_DEBUGGABLE UObject* Internal_Get(bool bEvenIfPendingKill) const
{
    FUObjectItem* const ObjectItem = Internal_GetObjectItem();
    return ((ObjectItem != nullptr) && GUObjectArray.IsValid(ObjectItem, bEvenIfPendingKill)) ? (UObject*)ObjectItem->Object : nullptr;
}

FWeakObjectPtr的解读就到这里。

看向TWeakObjectPtr
现在来看看TWeakObjectPtr是如何做好一个模板包装器的作用的。Get和IsVaild函数基本都是使用FWeakObjectPtr的实现,这里就不重复提及了。来看看一些不一样的操作,比如是TWeakObjectPtr和拷贝构造和赋值构造,如下所示:

template <typename OtherT,typename = decltype(ImplicitConv<T*>((OtherT*)nullptr))>
FORCEINLINE TWeakObjectPtr(const TWeakObjectPtr<OtherT, TWeakObjectPtrBase>& Other) :
    TWeakObjectPtrBase(*(TWeakObjectPtrBase*)&Other) // we do a C-style cast to private base here to avoid clang 3.6.0 compilation problems with friend declarations
{
}

template <typename OtherT,typename = decltype(ImplicitConv<T*>((OtherT*)nullptr))>
FORCEINLINE TWeakObjectPtr& operator=(const TWeakObjectPtr<OtherT, TWeakObjectPtrBase>& Other)
{
    *(TWeakObjectPtrBase*)this = *(TWeakObjectPtrBase*)&Other; // we do a C-style cast to private base here to avoid clang 3.6.0 compilation problems with friend declarations
    return *this;
}

这里使用decltype(ImplicitConv((OtherT*)nullptr))来做了一个是否能够隐式转换的检测,如果不可以隐式转换则直接在编译期报错。确保这里传入的类型都是兼容的,随后就是对TWeakObjectPtrBase初始化的操作。

接下来看到TWeakObjectPtr另外一个赋值构造函数,具体实现如下所示:

template<class U>
FORCEINLINE typename TEnableIf<!TLosesQualifiersFromTo<U, T>::Value, TWeakObjectPtr&>::Type operator=(U* Object)
{
    T* TempObject = Object;
    TWeakObjectPtrBase::operator=(TempObject);
    return *this;
}

这里使用TEnableIf和TLosesQualifiersFromTo两个模板元类,TEnableIf实现如下所示,很简单就能理解其用途,TEnableIf的主要作用是根据给定的条件(模板参数Predicate)来启用或禁用某个类型。

template <bool Predicate, typename Result = void>
class TEnableIf;

template <typename Result>
class TEnableIf<true, Result>
{
public:
    using type = Result;
    using Type = Result;
};

template <typename Result>
class TEnableIf<false, Result>
{ };

但是TLosesQualifiersFromTo是由多个模板类组合而来,首先是TAreTypesEqual,如下所示,TAreTypesEqual用于比较两个类型是否相等。如果给定的两个类型相等,它的Value成员变量将被设置为true,否则为false。

template<typename A,typename B>
struct TAreTypesEqual;

template<typename,typename>
struct TAreTypesEqual
{
    enum { Value = false };
};

template<typename A>
struct TAreTypesEqual<A,A>
{
    enum { Value = true };
};

随后是TCopyQualifiersFromTo,它的作用就是将一个类型的const和volatile限定符复制到另一个类型上。

template <typename From, typename To> struct TCopyQualifiersFromTo                          { typedef                To Type; };
template <typename From, typename To> struct TCopyQualifiersFromTo<const          From, To> { typedef const          To Type; };
template <typename From, typename To> struct TCopyQualifiersFromTo<      volatile From, To> { typedef       volatile To Type; };
template <typename From, typename To> struct TCopyQualifiersFromTo<const volatile From, To> { typedef const volatile To Type; };

最后TLosesQualifiersFromTo的实现如下所示,它的主要作用是判断从一个类型From复制限定符到另一个类型To时,是否会丢失任何限定符。

template <typename From, typename To>
struct TLosesQualifiersFromTo
{
    enum { Value = !TAreTypesEqual<typename TCopyQualifiersFromTo<From, To>::Type, To>::Value };
};

那么重新回到TWeakObjectPtr的赋值构造函数,使用这些模板类的目的在于检查只有在U类型的限定符不会在赋值时丢失时,才可以进行赋值操作。关于TWeakObjectPtr中的其他的函数就不再赘述,若大家感兴趣可以翻翻源码!

总结

TWeakObjectPtr是一个平时在开发过程中出现频率很高的工具,通常对于一些不确定其生命周期的UObject对象都可以通过TWeakObjectPtr包装一层以避免循环引用问题,并且可以避免对其UObject生命周期有所影响,确保引用的对象在其生命周期内被正确管理。当然在搞清楚TWeakObjectPtr的实现中也学到了很多其他知识,比如UObject在Unreal中是怎么被管理,当然还有一些模板元编程的技巧。

References
WeakObjectPtr

UObject管理篇


这是侑虎科技第1598篇文章,感谢作者不知名书杯供稿。欢迎转发分享,未经作者授权请勿转载。如果您有任何独到的见解或者发现也欢迎联系我们,一起探讨。(QQ群:793972859)

作者主页:https://www.zhihu.com/people/lllzwj

再次感谢不知名书杯的分享,如果您有任何独到的见解或者发现也欢迎联系我们,一起探讨。(QQ群:793972859)