游戏引擎学习第268天:合并调试链表与分组
回顾并为今天的内容设定基调
我们正在直播中开发完整的游戏,目前调试系统的开发已接近尾声。这个调试系统的构建过程经历了较长的时间,中间还暂停过一段时间去做硬件渲染路径的开发,并在已有的软件渲染路径基础上进行了扩展。后来我们又回到调试系统的开发中,把它进一步完善,现在我们引擎中的调试功能已经相当强大,达到了一个比较理想的停顿点。
虽然未来可能还会添加新的功能来扩展调试系统,但目前它已经可以满足我们的使用需求。我们计划逐步收尾这部分内容,并开始着手处理其他需要完善的系统,比如一些 AI 模块中不够理想的实现部分。同时我们会整理和统一现有的功能模块,确保各个部分都能可靠运行,以便为接下来的开发打下稳定基础。
目前我们已经完成了剪裁功能,使得所有调试窗口的内容都能被良好地限制在显示区域中,这使得用户界面更加整洁和有序。在调试界面方面,现在可以很好地查看我们需要的信息,整体功能非常实用。
接下来我们考虑在性能分析系统中添加内存分析功能。我们已经构建了一个简单但实用的 arena 分配器,想要进一步展示如何让它支持自动扩容,这样就不需要预先定义内存大小。许多使用者对目前预分配大小的方式感到疑惑,不知道如果无法提前知道大小该怎么办。其实将 arena 系统设计成可扩展结构是很容易的,只需对现有结构进行小幅修改即可。这将是我们下周的目标之一。
此外,我们还计划在 arena 系统中添加分配记录功能,让每次内存分配都能在调试界面中展示出来,这对调试内存相关的问题会非常有帮助。这些都将作为下一阶段的功能补充,带来更完整的调试体验。
目前系统中还有一些不够理想的地方需要整理,比如当暂停调试信息录制时,界面上的状态信息无法更新,这是因为状态数据没有被记录,自然也无法展示。虽然这不是特别严重的问题,但如果要把这个系统商业化可能需要解决这个问题。我们目前决定不去处理它,因为对我们来说影响不大。
克隆系统的用户体验还有些粗糙,比如窗口初始化时会出现一块空区域,用来移动窗口,但表现上显得比较奇怪。我们打算优化这个窗口系统,让窗口行为更加合理,并提供更多来自应用侧的控制,比如允许设置窗口出现的位置和内容布局。这将是今天的重点任务之一。
另外,我们也计划对调试界面中线程可视化的部分进行美化处理,比如改进线程绘制的方式,让用户更容易点击或放大查看感兴趣的区域,整体目标是提升交互体验。
除以上改进点外,目前整个调试系统的运行情况比较理想,已经足以支持我们进行后续开发。我们并不是为了发布调试系统,而是为了服务于游戏本体的开发,因此只要它足够好用,我们就可以停止进一步的打磨。
综上所述,今天我们将专注于改善窗口的绘制与控制系统,从这一部分着手,继续完善当前已有的基础。
game_debug.cpp
:删除大量多余的代码
我们打开代码后,注意到一个名为 DrawMainMenu
的函数。尽管名字叫“主菜单绘制”,但它实际上负责绘制所有的主菜单界面内容,不仅仅是一个菜单。它本质上是整个调试界面的绘制函数。
在查看这个函数的调用位置后,我们可以了解到整个UI系统的运作方式。代码中有一部分关于调试计数器的内容,那是以前用于统计信息的旧代码,在引入新的性能分析系统之前使用的。这些内容现在已经不再需要,可以视为遗留代码。
不过,为了确保没有遗漏,我们检查了一下那些代码的实际内容。发现其中似乎包含了一些关于条形图的绘制部分,可能是帧率或性能相关的可视化图表。然而这些功能现在已经被新的实现所取代,原本的功能也已经被整合到其他部分,因此没有必要保留原来的实现。
代码中还存在一个名为 ScopeToRecord
的部分,这部分同样属于旧的性能分析逻辑。考虑到我们现在有了更完善的分析机制,这部分内容也已经失去作用,可以完全删除。
综上所述,我们可以清理掉这些冗余的、已经过时的调试和性能分析代码,以简化系统结构,提升维护效率。
game_debug.h
:将 DEBUGDrawMainMenu
重命名为 DrawTrees
,并拆分出一个递归函数叫 DrawTreeLink
我们打算对 DrawMainMenu
进行重命名,因为它的功能已经远远超出“主菜单绘制”的范畴。它实际上负责绘制整个调试界面中的所有树状结构,而不是单一的菜单。我们决定将其改名为 DrawTrees
,这个名称更贴切它的实际作用。
随后,我们发现了一个旧函数,看起来是为了展示如何在不使用递归的情况下遍历结构体。回顾来看,这种做法意义不大,可能是当时为了避免传递额外状态参数而采取的做法,但现在看来逻辑不够清晰。因此我们计划将其改写为递归函数,使代码更易读、更直观。
旧实现中还使用了 VariableIntegrator
这个工具。这个工具的具体定义可以在 dy_variable_generator
中找到,但我们认为它已经不再适用于当前系统。它增加了不必要的复杂性,因此决定将其完全移除,转而使用常规的递归实现方式。
在实现递归版本时,我们将绘制逻辑重新组织为一个函数,命名为 DrawTreeGroup
。每次递归调用会处理一个树状分组,并在没有子元素时直接执行绘制操作。这也是递归函数“底部”的基本行为。当节点有子节点时,我们在绘制标题之后,根据是否需要展开,再递归处理每一个子节点。
为了实现子节点的处理,我们引入了 DrawTreeLink
函数,专门用于递归处理每一个子项。这些子项本质上是调试变量的链接,它们构成了一个完整的树状结构。通过这种方式,我们可以简洁地遍历并绘制整个树形界面结构。
另外,在重构过程中发现一些老变量,比如 debug_state_at_y
,已经失去了意义。这些变量曾用于处理旧版的状态系统,现在可以确定已经被新架构取代,因此可以安全地移除。这也包括一些曾用于控制交互是否绘制的条件判断逻辑,目前也没有保留的必要。
通过上述改动,我们能够清除冗余逻辑,提升代码可读性和维护性,并使整个绘制系统更加清晰、现代化。
我们正在重构调试绘制系统,将原来的 DrawMainMenu
改名为 DrawTrees
,这是因为它的职责远超过了菜单的范畴,实际上是整个调试树状结构的渲染函数。这个新名称更加贴切地反映了其作用。
接着处理子节点的逻辑。我们遍历子节点链表,忽略哨兵节点,通过 next
指针不断往下走,并在过程中递归调用绘制函数 DrawTreeLink
。我们意识到原本传入的渲染组(render group)参数其实在新的结构下根本不再需要,因此将其剔除,避免冗余。
布局(layout)参数是需要保留的,因为我们在递归绘制过程中需要不断更新布局位置。我们遵循一个三段式的调用参数结构:第一类参数是全局上下文状态,负责维护整个系统状态但作用域受限;第二类是与当前操作上下文相关的局部状态,在这里指的是布局状态;第三类则是当前实际操作的目标,也就是某个调试变量链接。
我们将 DrawTreeLink
改写为递归方式实现。每次调用时,我们检查当前节点是否有子节点。如果没有,就直接绘制;如果有子节点,并且当前节点处于展开状态,就递归调用自身绘制每一个子节点。为了实现递归深度的调整,我们在进入子节点绘制前更新布局的层级深度,绘制完毕后再还原。
在实现中,布局现在被作为指针传入,因此原来通过值访问布局的地方都进行了相应修改以适配指针形式。此外还考虑了一个细节:在遍历绘制每一个子节点时,传入的不只是布局信息,还需要包含树状结构信息。虽然树的结构在绘制过程中不会变动,可以将其合并到布局结构中,但我们认为目前传入两个参数(布局和树)也并无性能负担,因此暂时维持现状。
额外地,我们清理了不必要的代码,比如已经不再需要的 render_group
相关处理逻辑。现在它完全可以从函数参数中移除,简化了整个函数的调用方式。我们还调整了函数名称,修复了一些遗漏调用的问题,并逐步验证新的逻辑是否正确地被执行。
这次重构的目标是使调试树绘制逻辑更清晰、参数传递更合理、代码结构更符合实际用途,同时剔除那些冗余、过时、不再需要的部分,提升整体系统的可维护性和可读性。
改的有点多先改回来
game_debug.cpp
:让 DrawTrees
调用 DrawTreeLink
我们对 DrawTreeLink
函数的参数进行了整理和明确。这个函数现在需要接收四个参数:调试状态(debug state
)、布局信息(layout
)、变量树结构(tree
)以及变量组(debug variable group
)本身。这个变量组实际上是我们绘制过程的入口点,它代表一组调试变量链接。
通过分析,我们确定变量组内部实际包含了若干个调试变量链接(debug variable link
),这也就意味着我们一开始调用 DrawTreeLink
时的目标就是这些链接中最上层的一个。虽然从设计角度看,直接从某个链接开始进行遍历可能不够优雅,但实质上从变量组中提取第一个链接并从它开始递归绘制是完全可行的。这个处理方式虽然略显啰嗦,但在可维护性和实现复杂度之间取得了一个合理的平衡。
在这种结构下,虽然我们每次都需要传入 group
,但这并不会造成实质性的性能负担或设计混乱。我们也考虑过是否应该对结构做进一步调整以避免重复传递,但在当前规模下,这种传递方式依然是清晰且易于理解的。
此外,为了确保代码的逻辑清晰、语义明确,我们在调用 DrawTreeLink
时明确传入这四个参数。这样每次递归调用都拥有完整的信息上下文,可以更好地控制布局状态、访问调试状态,以及操作具体变量节点。
总的来说,这一阶段的调整进一步理顺了绘制逻辑的数据流,清晰划分了操作目标与其依赖上下文,使得后续代码的维护和理解更加直观和简单。我们也为之后更复杂的调试 UI 系统搭建了良好的基础。
栈溢出了吗
查看改进后的性能分析器,并思考接下来的处理方式
目前的绘制系统基本运行正常,显示效果与之前一致,而且已经修复了一个存在已久的问题:在第一次渲染时深度信息就被正确设置,避免了原本应该左对齐的内容被错误地缩进。这是一个显著的改进。
接下来思考的方向是如何改进当前系统的编辑与交互方式。虽然可以采用传统的“窗口-图标-菜单-指针(WIMP)”方式,即每个调试面板为可移动的小窗口,但这种方式存在一些弊端:
- 它会在游戏画面上叠加太多无关界面元素,影响对游戏内容的观察。
- 目标是在尽量不遮挡游戏画面的情况下显示调试信息。
- 希望调试工具界面轻量、非侵入,不带过多“窗口边框”之类的繁杂装饰。
因此,更倾向于保留当前这类“轻型”的调试界面,同时寻找方法来增强其可排列性和灵活性,比如支持用户以更自由的方式调整调试信息的位置,但不引入额外复杂的 UI 系统。
另外一个重要的思考点是关于“撕裂”(tear-off)操作的逻辑。当前的实现中,在用户将某个调试部分“撕出”成单独窗口时,会复制整个根元素。但这样做带来了一个问题:
- 如果当前元素是窗口的根节点,它本身就不应被再次复制并呈现。
- 正确的行为应该是只复制并显示该根节点下的子元素,而不是包含根节点本身。
这暴露出当前 CloneVariableGroup
函数的设计限制:它没有提供“仅克隆子节点”的选项。现阶段,它总是复制整个组,包括根元素,这在撕裂操作中是不合适的。
此外,还发现一个逻辑上的矛盾:理论上,在主树的绘制过程中根组的名称并不会被绘制,而在撕裂窗口中却意外地出现了这个名称。这提示我们存在某种不一致的行为。可能是因为绘制逻辑中某些路径没有遵守这一规则,需要进一步排查为何根组仍然被渲染。
总结当前的重点问题和下一步方向如下:
-
布局行为:绘制深度设置已经修复,左对齐问题解决。
-
UI 改进方向:不采用传统窗口化方式,追求轻量、无干扰、灵活排布的调试面板。
-
撕裂逻辑优化:
- 应只克隆子节点,不包含根节点本身。
CloneVariableGroup
函数需要扩展支持更灵活的克隆策略。
-
渲染逻辑排查:
- 理论上根节点名称不应在撕裂视图中被绘制。
- 实际上却被绘制了,需要检查
DrawTreeLink
中是否漏掉了相关判断逻辑。
总的来说,目前系统的渲染逻辑正逐渐趋于完善,接下来要聚焦于交互性、结构拆分策略以及一些边界情况的行为一致性。这样才能进一步提升调试工具的可用性和体验。
调试器:跳转到 DrawTrees
并在克隆后检查树结构
我们现在正在深入查看当前的代码逻辑,目的是理解在某些情况下变量组(variable group)是如何被克隆(clone)出来的,以及在这个过程中是否存在不必要的结构。
首先,我们打开调试视图,并克隆了一个实体。随后检查其结构,发现树(tree)与其对应的组(group)都如预期存在,并且名称为 “clone”。进一步跟踪克隆过程,发现当前实现中,当克隆变量时,即便源本身就是一个 group,我们仍然会创建一个新的 synthetic(合成的)variable group。这在逻辑上显得多余,没有必要。
因此,我们提出一个优化方向:
- 如果源变量本身已经是一个 group,就不再创建新的 group,直接克隆它的子项即可。
- 这样可以避免冗余结构的创建,并使克隆操作更符合直觉。
进一步分析 CloneVariableGroup
函数,发现它目前只处理整体克隆,而没有提供“仅克隆子项”的方式。当前处理方式是先判断是否需要创建新的组,如果不需要,就应该直接使用已有结构的子项进行构建。
在具体实现中,存在一种变量链接(variable link)与变量组(variable group)并存的情况。这两个结构在功能上非常相似,本质上都是一个调试节点的容器。思考后发现:
- 它们之间的区别几乎没有实际意义,仅仅是结构上的分离。
- 完全可以将这两者合并为一种结构,用同一个对象既代表 group 又代表 link。
这种合并能带来以下好处:
- 消除多余的结构,简化代码逻辑。
- 统一接口和行为,避免特殊情况处理。
- 减少内存占用(只多一个指针开销)。
之所以之前分开,是为了实现一种双向链表(doubly linked list)的便利操作,即通过在组结构中内置一个哨兵节点(sentinel)来管理子节点。但现在看来,这种实现细节并不值得额外增加逻辑复杂度。
我们意识到只需要稍微调整下数据结构的组织,就可以在保留双向链表便利的同时,合并 group 和 link,简化整个系统。实现这个想法唯一的障碍是当前对链表哨兵的依赖,但这是一个纯粹的实现细节,不应该决定整个系统的结构设计。
总结:
- 当前克隆逻辑有冗余,不该重复创建已存在结构。
- 应支持仅克隆子项的操作。
- 变量组与链接的结构应合并,功能高度重合,分离反而增加复杂性。
- 双向链表哨兵机制可保留,但不应主导结构设计。
- 这些改进可以让整体调试数据结构更清晰、简洁、易维护。
game_debug.h
:将 debug_variable_link
改为带伪哨兵的链表结构,并迁移所有相关内容
我们目前的工作重点是简化和优化变量组与变量链接(variable group 和 variable link)之间的关系结构,减少不必要的复杂性,提高代码的可维护性和可读性。以下是对我们处理过程的详细总结:
我们原先在实现中人为区分了“变量组”和“变量链接”两个结构体(或抽象概念),但这两个结构的功能高度重叠,甚至可以通过相同的数据结构处理。因此我们决定将两者合并,仅使用“变量链接”来表示所有节点,无论是组还是具体的元素。这种合并使得数据结构更加统一,避免了重复的逻辑和多余的内存指针。
为保持双向链表的便利性,我们实现了一个“哨兵节点”(sentinel node),这是一个假的变量链接节点,仅用于简化链表操作逻辑。我们为此添加了 get_sentinel
函数来获取哨兵节点,从而使原来所有依赖 group
的地方都可以改用 link
的哨兵逻辑。
在节点初始化时,我们通过 initialize_variable_link
或类似的封装初始化函数将自身的 next
和 prev
指向自己,确保结构完整。在需要新建节点的时候,调用 create_variable_link
函数,根据需要传入名称和调试信息,并设置子节点初始化为哨兵结构。我们去掉了原本冗余的 create_variable_group
和 add_group_to_group
之类的逻辑,因为这类操作完全可以通过统一的 add_link_to_group
或者 add_element_to_group
实现。
我们在代码中逐步将原先的 .group
和 .children
操作替换为直接的 .first_child
和 .last_child
,这种命名更符合链表结构的语义,同时也减少理解难度。
针对拷贝克隆逻辑,我们简化了 clone_variable_group
的实现,改用 clone_variable_link
,它直接操作 link 结构。在克隆子节点时,只在原始节点存在子节点时递归调用克隆操作,这使整个流程更加清晰且容易维护。
此外,在比较变量链接名时,我们也修复了潜在的问题:当变量名为 null 时,原先的字符串比较函数 strings_are_equal
未正确处理。我们准备调整该函数,让它能安全处理 null 情况,避免运行时错误。
最后我们通过统一的变量链接结构,极大简化了元素添加、组嵌套、克隆、初始化等核心操作的代码逻辑。所有相关逻辑都围绕 variable_link
类型展开,通过合适的封装函数,使其既符合数据结构要求,又减少重复代码。
整体来看,这次重构将原先臃肿、分裂的结构统一为一种简单清晰的数据结构,删除了大量冗余逻辑,使代码更加稳健、易读、易维护。接下来我们只需完成一些必要的调试和测试工作,确认新结构在所有用例中都能正常工作。
game_shared.h
:让 StringsAreEqual
支持处理 NULL 值
我们当前的目标是改进字符串比较函数 strings_are_equal
,使其更健壮地处理空指针(null)情况。原先的实现存在明显问题——当传入的其中一个字符串为 null(特别是参数 b
)时,函数没有进行检查,直接对其进行操作,这可能导致程序崩溃或逻辑错误。
因此,我们决定重新编写该函数的逻辑,添加对 null 情况的合理判断:
-
添加空指针判断:首先检查参数
b
是否为 null。如果是 null,我们不能再对它进行解引用或比较操作。 -
定义比较逻辑:
- 如果
b
为 null,那么我们认为a
也必须是一个长度为零的空字符串才算“相等”。这是出于逻辑一致性考虑:如果我们用 null 表示“没有名字”,那么与空字符串可以视为等价。 - 因此,在
b == null
的情况下,只要a.length == 0
,我们就认为两个字符串相等,返回 true。否则返回 false。
- 如果
-
正常字符串比较流程:
- 如果
b
不为 null,那么就执行我们原先的字符串比较逻辑:逐字符比较两者内容是否一致,或者用已有的字符串比较函数执行等价判断。
- 如果
-
结构简洁化处理:
- 使用一个
result
变量储存最终比较结果,并在判断逻辑中进行赋值。 - 这部分代码暂时保留原样,后续可以考虑进一步重构简化。
- 使用一个
通过这次修复,我们的字符串比较函数变得更加安全且符合预期逻辑,避免了因空指针带来的潜在运行时错误,并为后续模块依赖字符串名的判断提供了更稳定的基础。
查看性能分析器,确认克隆功能再次正常运行
我们已经成功完成了字符串比较函数的修改,并验证了其在克隆(cloning)操作中的正确性。此前由于字符串比较函数没有正确处理空指针,导致在克隆过程中出现逻辑错误或崩溃,现在问题已被修复。
具体来说:
-
字符串比较逻辑增强:通过添加空指针检查,我们改进了
strings_are_equal
函数。现在即使参数之一为 null,也能安全处理。特别地,当其中一个字符串为 null 时,函数会检查另一个字符串是否是长度为 0 的空字符串,若是则认为二者相等。 -
克隆功能恢复正常:修复后,我们重新测试了克隆逻辑,发现克隆流程能够正确执行,并保持预期的数据结构和内容完整性。
-
改动范围简单明确:整体更改较为集中,目标清晰,即将原本分散的、易出错的字符串比较逻辑合并统一,使其更稳健简洁。
-
逻辑整合与简化:此次修复进一步巩固了我们之前将变量组(group)和变量链接(link)统一成一种类型的设计目标,在数据结构上实现更高的一致性,简化了操作流程。
总之,这是一次结构清晰、效果明显的修正和简化,极大提升了系统的健壮性和可维护性。
game_debug.cpp
:让 CloneVariableLink
直接执行克隆并返回
着手处理一个剩余的小问题:我们希望在将某个元素从结构中“拉出”时,仅将其所属的组单独拉出,而不会留下原先的头部信息。为实现这一目标,我们需要对克隆操作的流程做一些简化和清理。
具体思路和步骤如下:
-
目标明确:我们希望在克隆调试变量(debug variable)时,不再执行之前那一套复杂逻辑,也不进行额外结构处理,而是直接克隆源对象本身并返回。因为现在所有变量都已经统一为相同类型,我们不再关心是哪一种,因此可以直接克隆。
-
避免头部残留:之前的逻辑在从某个组中提取变量时会保留头部元素,而我们现在希望完全分离,只克隆具体元素而不带上任何附加内容。
-
调整克隆行为:在修改过程中我们发现,当前的
addElementToGroup
函数不支持将元素添加到parent
为 0 的情况,也就是没有明确父节点的情况。为解决这个问题,我们考虑是否应支持这种“无父节点”的克隆行为。虽然目前不支持,但我们可以稍作修改以兼容这种逻辑。 -
逻辑解耦:我们意识到现有结构中的部分逻辑耦合度太高,例如父子关系处理部分。因此倾向于将这些部分进行适度解耦,使得“克隆”操作与“放入某组中”的逻辑能彼此独立。尤其在知道具体结构样式之后,这种解耦将更容易实施。
-
临时跳过某些处理:虽然当前还没有完全调整
addElementToGroup
的逻辑,但我们暂时跳过这部分处理,以便优先完成克隆结构本身的简化。在未来重构时,会进一步理顺父子关系和元素插入逻辑。
总结来说,我们对克隆逻辑做了进一步优化,使其更简洁直接,同时也为后续重构留下接口扩展的空间。这是一次结构层次清晰的优化过程,提升了系统的灵活性和可维护性。
测试性能分析器中的克隆功能
我们现在对结构进行了调整,使得当我们拉出一个 profile(配置文件)时,它变得更合理了。现在的 profile 表现为一个独立漂浮的实体,而不是之前那种还会附带一个额外部分的结构。这样的处理方式更加符合预期,因为我们可以根据需要再去绘制附加部分,但不再被强制附带。
当前的状态具有以下几个优势和改变:
-
结构更清晰:profile 被单独拉出后变得干净,不再包含冗余的头部或附属结构。整个组件结构变得更简洁、更易于管理。
-
行为更加灵活:现在我们在处理图形绘制时,如果需要其他附加部分仍然可以绘制它们,但不再被强制依赖,这提升了组件的灵活性和可控性。
-
避免强制依赖:以前必须绘制的部分现在变成了可选项,这避免了不必要的渲染开销和逻辑复杂性。
-
交互逻辑检测:我们尝试在界面中选择一些元素,发现部分元素无法被选中。经过检查发现,这是因为缺少针对“间隙”元素(gap)的交互逻辑(如前置规则)支持。
-
可被克隆的测试:我们进一步测试了对这些元素的克隆操作,结果验证成功,说明新的结构支持克隆逻辑正常运行,没有出现预期外的问题。
整体来看,我们已将配置文件结构与渲染逻辑有效解耦,同时增强了交互与克隆能力,为后续开发和维护打下了更坚实的基础。
问答环节
我感觉这期节目我没太跟上树结构的设计进展,可以帮我总结一下吗?
我们在这一阶段完成了以下几项工作,具体如下:
-
改进字符串比较函数:优化了字符串相等的判断逻辑,加入了对
null
或无效值的检查。之前的实现中如果b
是null
,函数并不会正确处理,而现在加入了判断逻辑:当b
不存在时,仅当a
的长度为零时才认为两者相等。这让判断更加健壮,避免了潜在的错误。 -
修复克隆逻辑中的问题:在执行克隆操作时,原本会错误地克隆出带有不必要附加内容的结构,尤其是在克隆调试变量(debug variable)时。我们更改了克隆实现,使其直接返回源对象的克隆,不再包含原先额外生成的冗余部分。这样一来,克隆出的元素更干净、结构更合理。
-
支持空父元素克隆:我们让
addElementToGroup
函数能接受一个“无父元素”(即 parent 为 0 或 null)的情况。这使得可以将元素克隆出来成为独立个体,而不需要强制挂载在某个父节点之下。这是为支持 profile 独立存在而做出的必要调整。 -
调整结构让配置项独立:我们将 profile 从原本与 header 紧耦合的状态中解耦,使其可以单独存在。这意味着 profile 不再自动附带 header,当需要时可以选择绘制,而不是默认绑定。这种方式更灵活,更易于后续管理和扩展。
-
处理选择与交互逻辑的差异:在测试时发现某些元素无法被正确选中,排查后确认是因为这些元素(如“间隙”gap)没有设置交互优先级(precedence)。这部分虽然还未完全解决,但已经明确了问题根源,未来可以继续优化。
-
确认克隆功能恢复正常:经过调整之后,所有克隆相关操作都已经重新正常工作,包括结构清晰度、交互逻辑、是否正确继承属性等方面都已验证无误。
整体来说,我们通过一系列结构与逻辑层级上的优化,使得模块的行为更加一致、灵活且符合预期,同时也为未来功能扩展和用户交互打下了清晰基础。
黑板讲解:链式结构
我们最近进行了一个结构上的优化,主要集中在链表组织结构的精简和统一管理上,具体内容如下:
-
使用双向环形链表结构:我们构建了一个带有前向(
next
)和后向(previous
)指针的链表,使得所有节点在逻辑上形成一个闭环(ring)。每个节点都指向其前一个和后一个节点,这种结构避免了空链表的特殊处理。 -
引入哨兵节点(sentinel):为了简化插入和删除逻辑,我们在链表中添加了一个永不被删除的哨兵节点。初始状态下,这个哨兵节点的
next
和previous
都指向它自身。这样,无论什么时候添加新的节点,只需执行统一的插入逻辑,不需要判断链表是否为空。 -
链表插入逻辑:添加新元素时,只需设置其前向指针指向哨兵,后向指针指向哨兵的
next
,再将哨兵的next
的previous
指向新节点,以及哨兵的next
指向新节点。这样在链表头部或中部插入元素的逻辑保持一致,极大简化了代码。 -
结构设计优化:共享类型统一化
- 原本存在两个结构体:一个是
Group
,包含元数据和一个哨兵链表;另一个是Link
,包含next
、previous
和实际数据字段。 - 为了避免维护多个结构体,我们决定统一这些结构,只保留一个通用的
VariableLink
类型,使其既能作为数据节点,又能充当哨兵头部。
- 原本存在两个结构体:一个是
-
实现手法:结构重用与类型欺骗
- 我们在结构体中保留了
next
和previous
指针,但去掉了多余的字段。 - 对于哨兵节点,我们只需要它的前后指针,不需要其携带任何数据。因此,我们在代码中通过类型转换,欺骗编译器把只包含前后指针的部分“当成”完整的结构使用。
- 我们在结构体中保留了
-
结果结构与双环嵌套逻辑:
- 每个节点都可以拥有子节点,同时也是兄弟节点链中的一环。
- 节点结构中包含四个指针:
next
、previous
(指向兄弟节点),以及first
、last
(指向子节点)。 - 哨兵节点实际上是通过前两个指针创建的,仅作为头部,不参与数据处理。
- 这样,一个节点结构就既能表达出它在兄弟链中的位置,也能表达其对子节点的组织,从而支持嵌套的多层结构。
-
设计上的技术思考与语言限制不满:我们认为这种直接操作内存结构的方式,比许多高级语言所提供的“安全”抽象更加灵活和高效。语言设计不应阻碍程序员手动构建高性能结构,而应该提供必要的支持让这类底层优化成为可能。
-
最终目标:通过结构体合并、逻辑简化、指针共享,使代码更具表达力,维护成本更低。现在每个
VariableLink
都能独立存在于任意层级,既可以作为叶子节点,也可以作为子树根节点,结构通用性大大提升。
总的来说,我们成功将链表节点、组管理结构和子节点组织统一到一个抽象中,极大提高了系统结构的清晰度和灵活性。
好的,下面我们用一个具体的例子来说明这个结构是如何工作的,以及我们是如何通过哨兵节点和双向环形链表来统一管理节点的。
假设结构定义(简化):
我们定义一个结构体 VariableLink
,它具有以下字段:
struct VariableLink {VariableLink* next; // 下一个兄弟节点VariableLink* previous; // 上一个兄弟节点VariableLink* first; // 第一个子节点VariableLink* last; // 最后一个子节点void* data; // 实际存储的数据
};
树状结构举例:
假设我们有如下结构:
Group A
├── Child A1
│ ├── SubChild A1a
│ └── SubChild A1b
└── Child A2
我们在内存中用 VariableLink
表示,每个节点都带有一个子链表(通过 first/last 指针表示),并且兄弟之间通过 next/previous
连接。
节点结构举例:
-
Group A 是一个
VariableLink
,它有:first
指向 Child A1last
指向 Child A2next/previous
可能指向别的 group,如果它是一个子项data
是 “Group A”
-
Child A1 是 Group A 的第一个子项,它有:
previous
= NULL 或哨兵next
= Child A2first
指向 SubChild A1alast
指向 SubChild A1bdata
= “Child A1”
-
Child A2:
previous
= Child A1next
= NULL 或哨兵data
= “Child A2”- 没有子节点,所以
first/last = NULL
如何插入新子节点?
以在 Child A1 下插入新的子节点为例:
-
当前情况:
- Child A1 的子链是空的,
first == last == sentinel
(哨兵自己)。
- Child A1 的子链是空的,
-
插入 SubChild A1a:
- 设置其
previous = sentinel
- 设置其
next = sentinel
- 修改
sentinel.next = SubChild A1a
- 修改
sentinel.previous = SubChild A1a
- 设置其
由于使用了环形结构和哨兵节点,我们可以统一地插入和删除,而不需要判断“是否是第一个元素”或“链表为空”等边界条件。
为什么这种结构有用?
- 支持任意层级的嵌套结构(如树形 UI 元素、JSON 数据结构等)。
- 插入、删除节点只需要处理固定的几行指针操作,逻辑简单、效率高。
- 所有节点使用相同的数据结构,不需要额外的组/子组类型区分。
- 利用哨兵避免空链和特殊情况处理。
黑板讲解:用递归函数遍历树
最开始进行的工作是将原本对树结构的非递归遍历方式,改成了递归遍历。
原本我们实现的是一个典型的树形结构,例如:
foo
├── child1
│ ├── subchild1
│ └── subchild2
└── child2
这种结构用于展示类似列表视图的界面,支持展开与折叠。这个界面是动态可交互的,平常经常在操作时会打开某个节点,再进入其子节点或折叠回来。
原始实现方式(非递归)
原来我们采用了非递归的方式来遍历树结构:
- 用一个
while
循环(或for
循环), - 自己维护了一个“栈”(stack)来记录遍历路径和状态,
- 每次处理一个节点并决定是否推进、退回或展开子节点,
- 这在逻辑上是正确的,但可读性和维护性不佳。
修改为递归实现
后续我们觉得没有必要手动管理栈结构。因为:
- 语言本身的调用栈已经可以很好地替我们处理递归逻辑;
- 这样代码更简洁、更易读,尤其是对于层级结构的遍历;
- 也更易于未来维护,不再需要管理栈的推入/弹出等操作。
因此,做出的修改如下:
- 删除了原来的手动循环与栈管理逻辑;
- 改为使用一个递归函数,比如叫
DrawTreeLink
; - 每次递归函数被调用时,处理当前节点;
- 如果当前节点有子节点,就对每个子节点再次递归调用
DrawTreeLink
; - 如果某个节点本身还有子孙节点,就会自动层层递归下去直到叶子节点。
优势总结
- 利用了语言内置的调用栈,省去了手动维护的复杂度;
- 逻辑清晰,结构自然,容易看出树的递归特性;
- 代码量更少,可读性大幅提高;
- 几乎没有性能损耗,因为遍历本质没有变,只是转移了控制流程的方式。
当然可以。以下是一个简化版的伪代码示例,演示如何用递归方式来遍历并绘制一个树结构。
我们假设每个节点是一个结构体 Node
,包含指向第一个子节点和下一个兄弟节点的指针:
struct Node {char* name;Node* first_child;Node* next_sibling;
};
递归遍历函数示例
void DrawTree(Node* node, int indent) {if (node == NULL) return;// 打印当前节点,带缩进for (int i = 0; i < indent; ++i) {printf(" "); // 缩进}printf("%s\n", node->name);// 递归绘制子节点,缩进 +1DrawTree(node->first_child, indent + 1);// 然后绘制下一个兄弟节点,缩进不变DrawTree(node->next_sibling, indent);
}
示例数据结构
Node a = { "A", NULL, NULL };
Node b = { "B", NULL, NULL };
Node c = { "C", NULL, NULL };
Node d = { "D", NULL, NULL };
Node e = { "E", NULL, NULL };// 构建结构:
// A
// ├── B
// │ └── D
// └── C
// └── Ea.first_child = &b;
b.next_sibling = &c;b.first_child = &d;
c.first_child = &e;
调用方式
DrawTree(&a, 0);
输出结果为:
ABDCE
这个递归模式比手动维护栈逻辑(如 while
+ push/pop
)更加直观。我们在修改中也使用了类似方法,让每个节点负责自己的递归绘制,使整体代码更简洁且易于扩展。
关于链表和缓存友好性的看法?我发现用简单的数组,并在长度变化时复制,速度要快得多
我们讨论了对链表与数组在性能表现上的一些思考和实际经验。
我们倾向于不喜欢使用数组的主要原因,是因为它们在修改顺序和添加元素时不够灵活,代价不确定。比如:
- 如果想在数组中插入一个元素,可能需要将整个数组内容往后移动一部分,这在数据量大时开销很大;
- 如果需要重新排列数组中元素的顺序,也会产生类似的成本;
- 有时候插入或删除操作可能是“免费的”,但有时候代价却非常高,比如触发重新分配内存。
我们不喜欢这种代价不稳定的操作模式。在处理一些性能敏感的代码时,我们更倾向于结构上成本清晰、稳定的数据结构。
在实际做性能测试时,我们也会尝试对比,比如用数组和链表分别实现一段逻辑来观察差异。虽然很多人认为“数组比链表快”,但我们在实际项目(例如在 The Witness 中)做性能优化时,发现它们之间的差异其实非常小,几乎可以忽略,甚至在某些场景下链表表现也非常好。
所以我们更在意操作代价的可控性和结构的表达清晰度,而不是一味追求缓存友好性或理论性能最大化。毕竟在现代 CPU 上,缓存行预取、分支预测等因素都会影响最终表现,而链表和数组在真实场景中的差异往往没那么大。
黑板讲解:缓存友好性
在讨论链表与数组的性能时,主要涉及到缓存友好性的问题。首先,链表与数组在缓存友好性上的区别并不像人们想象的那么明显,尤其是当数据量较小时,二者的性能差异并不显著。原因在于,无论是链表的指针还是数组的元素,它们的缓存命中率大致相同,除非数据量足够大,超出了缓存的大小限制。
具体来说,当链表的每个元素(即每个节点)都有一个指向下一个节点的指针,而数组则是一个连续的内存块。如果链表和数组的大小都能够适应缓存,那么它们的缓存友好性就差不多。然而,如果链表结构分散在内存中,每次分配内存时都会使数据散布在不同位置,导致缓存不一致。举个例子,假设每个链表节点是一个较大的结构体(比如68字节),这会导致每次加载链表节点时,多余的缓存数据(即超过缓存行大小的部分)会污染缓存,浪费缓存空间,从而影响性能。
然而,如果链表节点的大小较小,且每个节点在内存中是紧密排列的,那么它们的内存布局就像一个数组,能够更好地利用缓存。相反,如果每个链表节点都使用动态内存分配(如使用new
关键字分配内存),那么这些节点会散布在不同的内存区域,导致缓存污染。
总体而言,链表的性能问题通常发生在对内存的频繁分配和释放上,特别是在高性能要求的场景下。如果能预先知道数组的大小并且不频繁变更,数组通常会比链表表现得更好,尤其是在不需要频繁修改数据结构的情况下。而在需要频繁插入和删除元素时,链表的优势可能更为明显,因为它的插入和删除操作不需要移动大量的数据。
因此,虽然有些人认为数组在性能上优于链表,但实际情况并非总是如此。链表在某些场景下的性能问题主要是由于内存分配的不连续性,而并非链表本身的缺陷。在性能要求不是极端高的情况下,链表和数组的表现差异往往并不大,链表也不会带来严重的性能问题,除非你在极端性能敏感的场景下,才需要特别注意内存的布局和缓存的使用。
总结来说,链表和数组各有优劣,关键取决于具体的应用场景和性能需求。在大多数情况下,链表的缺点并没有被过分放大,它在灵活性和扩展性方面的优势可能会弥补一些性能上的不足。
并发性也很重要。如果需要多个线程同时操作列表,链表可能更合适
在讨论并发性时,链表比数组有显著优势,尤其是在需要多个线程同时操作时。数组在并发操作下表现较差,因为每当需要对数组进行修改(如添加元素)时,必须对整个数组进行锁定和复制。这种操作不仅复杂,而且可能导致多个线程需要等待彼此完成任务,造成性能瓶颈。
相比之下,链表在并发情况下更高效。链表可以在进行修改时,仅需要对当前节点进行锁定或更新,而不需要操作整个数据结构。这使得链表在多线程环境下更具优势,因为它的插入和删除操作相对简单,能够减少线程间的等待时间和资源竞争。对于多线程同时访问的数据结构,链表的锁定范围较小,因此减少了线程之间的相互干扰。
总结来说,当需要处理并发操作时,链表的性能通常优于数组,因为链表能够更轻松地进行局部的锁定操作,而数组则需要处理全局锁定和复制的问题,这在多线程环境下可能导致较大的性能损失。
你怎么看对象池?作为链表和数组之间的一种折中方案?
对于对象池作为链表和数组之间的中间方案,整体上没有特别强烈的看法。就像之前提到的,我并不是在宣传链表的优越性,只是想表达出对于“总是使用数组”这种说法的质疑,因为我并没有看到任何足够有力的分析表明链表在大多数情况下就不好用。链表并不是不适用的结构,实际上它在很多情况下依然是有价值的。
如果关心性能,确实需要根据具体的操作类型来评估,无论是链表、数组,还是其他数据结构。每次操作都会有不同的影响,因此需要根据具体情况进行选择。如果你所做的操作对性能要求并不高,那么其实不需要过于担心链表的问题。在我个人经验中,链表并没有让我觉得它让程序变得极其缓慢,甚至遇到过不可避免的性能瓶颈。相反,若需要,可以很轻松地将链表改为数组,这通常并不会花费太多时间。
实际上,对于许多任务,选择链表或数组并不那么重要,更多是看哪个结构对当前问题来说更为简便。通常,我会选择自己当前最容易使用的结构,而不至于过多纠结在性能优化上,特别是在手动编写代码时,选择链表比数组更方便。
数组有很多我不喜欢的地方。比如,数组中的指针不是很稳定。如果从数组中删除元素,数组中的指针会发生变化,这对于一些情况来说是非常不便的。此外,数组作为数据结构并不是很适合处理动态变化的操作,比如插入或删除元素。这些都是我不喜欢数组的原因,因此在大多数情况下,我倾向于使用链表,除非我能明确看到数组在某些特定情况下明显更优,我才会考虑使用它。