今天来学学LRU在UE5.6引擎里的实现,之前会写大致的LRU算法,也没太关注内存有没有泄漏什么的。顺带学学C++的RAII,深入理解一下。
代码文件在此:
1 | Engine/Source/Runtime/Core/Public/Containers/LRUCache.h |
一点一点看吧。
首先,这份代码先写了一个TLruCache的键比较器模板
1 | /** |
Matches(KeyType A, KeyType B)主要判断两个键是否相等。[[nodiscard]]是C++17的属性,提醒用户必须使用返回值。FORCEINLINE就是UE的宏,如其名,强制内联以提升性能。这里用了return (A == B);也就是说KeyType类型必须实现==比较运算符。
GetKeyHash就是计算键的哈希值。
然后进入正题,TLruCache。首先把一堆接口函数折叠起来,看看私有成员:
1 | class TLruCache |
先了解一下私有成员:LookupSet就是一个哈希表,用来存储FCacheEntry*指针。LeastRecent应该就是指向最旧条目的那个指针。MostRecent就是最新的了。至于FCacheEntry为什么起这名呢?一会翻到了再看。
类的开头就是FCacheEntry结构体的定义:
1 | /** An entry in the LRU cache. */ |
先看 FCacheEntry 结构体。它是一个双向链表节点,包含四个成员:Key 和 Value 存储键值对数据;LessRecent 和 MoreRecent 是两个指针,用于链接节点。LessRecent 指向时间更早的节点(类似平常写的pre,next指针),MoreRecent 指向时间更晚的节点。链表的方向是从最新到最旧,每个节点通过这两个指针形成双向链接。
结构体提供了两个构造函数。第一个构造函数同时接收键和值,用于完整初始化条目,链表指针都设为 nullptr。第二个构造函数只接收键,用于需要延迟初始化值的场景(如 AddUninitialized_GetRef),此时值会使用默认构造或由调用者后续设置。
LinkBefore 方法将当前节点插入到指定节点之前。它先设置当前节点的 LessRecent 指向目标节点,如果目标节点存在,再将目标节点的 MoreRecent 指向当前节点。这样就把当前节点插入到了更靠近链表头部的位置。需要注意,这里只建立了局部连接,如果当前节点原本在链表中的其他位置,调用前通常需要先调用 Unlink 断开原有连接。
Unlink 方法将当前节点从双向链表中移除。它先检查前驱和后继节点,如果前驱存在,将前驱的 MoreRecent 指向当前节点的后继;如果后继存在,将后继的 LessRecent 指向当前节点的前驱。这样即使移除了当前节点,链表的其他部分仍然保持连接。最后将当前节点的两个指针都设为 nullptr,完成解除链接。上面这个插入删除操作就是链表结点的插入删除,没有什么高深的地方。只不过,这种操作在面试写的时候都是把插入删除方法封装在Node结点外面的,这里直接放在了结点的Struct里面,通过传递指针完成操作。
接下来是 FKeyFuncs 结构体,它继承自 BaseKeyFuncs<FCacheEntry*, KeyType>。这个适配器的作用是让 TSet 能够存储 FCacheEntry* 指针,同时使用 KeyType 作为查找键。TSet 需要从存储的元素中提取键,但存储的是指针类型,查找用的是键类型,所以需要这个适配器来桥接。
FKeyFuncs 提供了三个静态方法。GetSetKey 方法从 FCacheEntry* 指针中提取键,直接返回 Entry->Key,这样 TSet 就知道如何从存储的指针中获取键了。Matches 方法比较两个键是否相等,它委托给模板参数 KeyComp 的 Matches 方法,这样支持了自定义键比较逻辑。GetKeyHash 方法计算键的哈希值,同样委托给 KeyComp 的 GetKeyHash 方法,用于哈希表的定位和查找。
当 TSet 需要查找某个键时,它会使用 FKeyFuncs 来进行操作。TSet 遍历哈希桶中的 FCacheEntry* 指针,对每个指针调用 GetSetKey 得到对应的键,然后调用 Matches 方法比较该键与查找的键是否相等。如果相等就返回该指针。这样 TSet 就能存储指针类型,但用键类型进行查找,实现了类型转换的适配。
LRU所需的数据结构如上,定义完毕。
先把第一个public翻开,看看里面是什么:
1 | public: |
构造函数和析构函数都比较好理解,就不展开看了。
再下面就是各种插入删除结点的接口,实现的思路和平常一样。
用工业级的代码与我们平常写的做一个对比。平常面试写的时候,类型单一,限制很多。见:https://leetcode.cn/problems/lru-cache-lcci/description/
简易LRU实现将键类型固定为 int,UE引擎的LRU缓存采用模板设计,KeyType 可以是任意类型。这是适配器存在的原因之一,但不是唯一原因。
简易实现使用 unordered_map<int, Node*>,哈希表直接以 int 作为键进行存储和查找,类型匹配,无需额外转换。即便改为模板类以支持多种键类型,例如 unordered_map<KeyType, Node*>,类型仍然匹配,因为键类型就是模板参数,哈希表可以直接处理。
UE引擎的实现采用了不同的设计。它使用 TSet<FCacheEntry*, FKeyFuncs>,哈希表中存储的是 FCacheEntry* 指针,但查找时使用的是 KeyType,从而造成类型不匹配:存储的是指针类型,查找传入的是键类型,两者在类型系统上无法直接对应。为解决这个问题,UE 引入了 FKeyFuncs 适配器。适配器提供 GetSetKey 方法,告知哈希表如何从存储的 FCacheEntry* 指针中提取 KeyType 类型的键。当调用 LookupSet.Find(Key) 时,哈希表会遍历存储的指针,对每个指针调用 GetSetKey 提取键,再与传入的键比较,从而找到匹配的条目。
除了解决类型不匹配问题,适配器还承担了支持模板灵活性的职责。由于 UE 的 LRU 缓存是模板类,KeyType 可以是 int、string、自定义结构体等任意类型。不同键类型可能需要不同的比较和哈希方式,因此适配器还提供 Matches 和 GetKeyHash 方法,这些方法委托给模板参数 KeyComp 处理,使得可以自定义键类型的比较和哈希逻辑。这种设计虽然增加了代码复杂度,但提供了更大的灵活性,能够适应各种不同的键类型和使用场景。
总结来说,适配器的存在主要源于两个因素:一是存储指针但用键查找导致的类型不匹配问题,二是模板设计带来的类型灵活性需求。即使简易版本改成模板支持多种键类型,只要使用 unordered_map<KeyType, Node*> 这种类型匹配的设计仍然不需要适配器。UE 版本需要适配器主要是因为它选择了“存储指针 + 用键查找”这种设计模式,这种模式在提供灵活性的同时也带来了类型系统上的挑战,适配器就是用来解决这些问题。