游戏引擎学习第176天

今天的计划

今天我要做的是,首先讨论一下调试服务(debug services),它们是什么,它们应该如何工作。然后我们可能会开始实现它们,但我不认为我们能做到很远,因为首先要做一些基础的准备工作。通常每次从休息回来,我们会开始一个新话题,这时需要花点时间让大家都理解我们要做什么,确保大家在同一页上。所以这一部分可能会花点时间,但如果有时间的话,我们还是会开始写代码。估计这一周的内容主要是给项目加入一些调试的基础设施和功能。

测试驱动开发并不能解决游戏编程中的更难问题

常常会看到新观众问到"你怎么看待测试驱动开发(TDD)?"这个问题。这个问题非常常见,我并不完全理解为什么,但根据一些人事后给我的解释,很多人问这个问题可能是因为测试驱动开发(TDD)在网页开发领域是一个流行的术语。无论如何,我经常会遇到这个问题,你们也都听过我的回答。

我通常会说,游戏开发并不适合测试驱动开发,因为在游戏中,测试驱动开发能够测试的部分并不是最难调试的部分。是的,我们完全可以对当前的各个组件进行测试驱动开发,并且它会发现一些bug,也许我们确实会在项目中做一些测试驱动开发,确实可以找出一些问题,但测试驱动开发并不能节省游戏项目的开发时间。原因在于,游戏中那些真正让你夜不能寐、引发严重问题的bug并不是单元测试能够发现的那种。它们通常是更为微妙、更具算法性质、更多是时间性的问题,这些问题往往发生在很长一段时间内,有时是几分钟,有时是几小时的游戏过程中,而这些问题通常无法通过简单的单元测试来发现。

这也是为什么很多游戏开发团队,尤其是一些大型游戏开发团队,会做非常高随机性的测试。比如他们会有像BOT一样的程序,自动进行各种随机操作,试图找到游戏中的崩溃、卡住的地方等问题。这也是为什么很多游戏会进行Beta测试和早期访问,试图让玩家帮助发现问题,尽管现在很多时候开发者仅仅把这些当作一种"借口"。即使你是一个非常优秀的团队,做了大量艰难的工程工作,你仍然会遇到那种非常复杂的情况。比如AI的长逻辑链条可能会导致一些问题,这些问题用单元测试几乎是无法捕捉到的。所以,测试驱动开发并不能解决所有游戏开发中的问题,尤其是那些复杂、时间性较强的bug。

调试服务的目的

调试服务和调试基础设施在游戏开发中是非常常见的,尤其是对于那些具有一定复杂性的项目。调试服务的设计目的是为了帮助更容易地发现那些难以察觉的bug,并且通过提供可视化的方式,让开发者能够在游戏中看到那些原本很难察觉的bug,或者在游戏运行时无法直接看出发生了什么情况的bug。这些服务可以通过一种便捷的方式将bug展现出来,甚至可以在稍后以某种二次方式呈现,从而帮助开发者更清楚地了解到底发生了什么。

调试服务是几乎所有游戏开发中不可或缺的部分,特别是在专业质量的项目中,不论项目的规模大小,它们都会有内建的调试服务。这些调试服务通常以几种形式存在,比如调试控制台,可以输入像《quake》那样的控制台命令,或者是开发者专用的调试覆盖图、HUD(平视显示器)等,这些都是在游戏的正常渲染之上叠加的,用来展示额外的量值、信息等,这些信息对于玩家来说可能是看不到的,但对程序员来说非常重要。

调试服务的目标有两个,首先是通过这些工具帮助开发者快速暴露出游戏中的bug,其次是提供一些额外的可视化信息,帮助开发者能够更加清楚地观察到那些难以在游戏运行时发现的问题。这些工具和服务是开发者解决复杂问题、提高游戏质量的重要支持工具。

在游戏中,HUD 就是这种技术的应用,通常通过屏幕叠加显示关键信息(如生命值、得分、地图、任务目标等),让玩家不必切换视角即可实时获取重要数据。

1) 将错误引导到表面

在游戏开发中,调试服务的一个重要目的就是帮助发现难以察觉的 bug。通常,在观察一个场景时,可能会看到似乎有一些问题,但并不确定具体是哪里出了问题。这个时候,可以通过开启调试控制台或调试 HUD(平视显示器)来帮助定位问题。调试 HUD 可能包含一些滑块,可以用来关闭某些功能,或者调节一些参数,比如关闭植被或阴影等。这些调整可能会暴露出被其他元素掩盖的 bug,从而帮助发现问题所在。

另外,调试工具还可以通过在场景中添加更多的 AI,或者引入更多的怪物、投射物等方式进行压力测试。这些方法可以模拟游戏通常不容易出现的场景,从而强制出现一些 bug,这些 bug 可能只有在少数情况下才能通过玩家的测试发现。通过这种方法,可以更有效地暴露和解决这些潜在的 bug,避免它们在游戏发布时出现。

2) 定位明显存在但难以精确找到的错误

在开发游戏时,遇到 bug 是常见的,但有时我们明知道存在 bug,却很难定位它的来源或原因。比如,当游戏在大多数时间里运行得非常流畅,但偶尔会出现一个帧丢失或卡顿现象,这时候没有调试服务的帮助,我们很难知道是什么原因导致了这一问题。它可能是很多不同因素的综合结果,完全没有线索可以追踪。

这时,调试服务就显得非常重要。通过调试服务,我们可以跟踪每一帧的时间消耗,并将这些数据保存在一个滚动缓冲区中。当出现问题时,我们可以通过按下一个按钮来暂停并查看记录,发现哪些帧上有明显的时间峰值。这样,我们可以快速定位到问题所在,并深入分析造成延迟的原因。例如,我们可能会发现 AI 在某个函数上花费了大量的时间,这可能是一个性能问题,接下来就可以针对性地进行优化。

如果没有这样的可视化工具,我们就很难发现并理解这些潜在的问题,因为游戏中的事务繁杂多样,不同的部分消耗着不同的资源,很容易让问题被隐藏在众多任务中,无法被发现。因此,调试服务的核心目标就是收集运行时的信息,帮助开发人员在事后分析并找出 bug 的根源。尽管游戏开发者随着经验的积累,遇到这种情况的频率可能会减少,但即使是资深开发者,在面对复杂的成熟游戏时,也常常会遇到难以解释的 bug。

因此,调试服务在游戏开发中的作用非常重要,即便是经验丰富的开发者,也常常依赖这些工具来帮助他们快速定位和解决问题。不过,我们也要避免把太多时间投入到调试系统的开发中,因为每一分投入调试系统的时间,都是在减少游戏开发本身的时间。所以,构建一个有效的调试服务系统需要平衡,不仅要确保它足够强大,能够帮助解决复杂问题,还要避免过多的时间浪费在调试系统的开发上,确保能够节省更多时间来集中精力完成游戏的核心开发。

我们将专注于解决困难问题的多功能调试服务

我们希望专注于构建高效、易于使用的调试服务,能够在最少的工作量下实现最大的效果。也就是说,通过这些调试工具,能够帮助我们快速找到并可视化多种类型的 bug,而不需要投入过多的开发精力。通常,难以定位的 bug 会出现于问题的表现和其原因之间存在一定的时间或空间的分离。换句话说,当我们看到 bug 的表现时,很难立即确定问题的根本原因。

例如,某些 bug 的表现很明显,但我们需要通过多次的检查和思考才能确定造成它的具体原因。这种情况下,调试服务就变得非常重要,因为它们能够帮助我们从多个角度进行 bug 的追踪和可视化,缩短找到原因的时间。通常,如果我们能够立刻看到 bug 并明确其原因,那就不需要调试服务的帮助。然而,对于那些难以发现和定位的复杂 bug,调试服务显得尤为重要。

这些复杂 bug 通常是在程序的不同部分之间相互作用的结果,它们可能跨越了多个函数、时段或位置,因此问题的源头和表现之间存在时间或空间上的错位。为了有效找到这些 bug,我们需要设计调试服务来帮助我们更好地跟踪和可视化这些问题,以便及时定位和修复。

日志。调试帧率问题的示例

为了处理这些难以定位的 bug,日志记录是一个非常有效的工具。这里的日志并不是指简单的 UNIX 日志文件,它是一个可以帮助我们记录程序信息,并且在后续查询或以有用的方式展示的机制。例如,假设我们遇到一个帧延迟的问题,游戏程序的帧率通常是稳定的,每一帧大概消耗相同的时间(例如 16 毫秒),但偶尔会出现某一帧的时间明显增加,比如突然从 16 毫秒增加到 35 毫秒,而后续帧又恢复正常。这种情况我们在实时运行时很难察觉,因为当我们发现这个问题时,已经太晚了。

简单地说,即使我们在程序中添加了检测代码来记录每次帧的消耗时间,并且当帧时间超过了预定的阈值时进行暂停,实际上问题已经发生,导致帧的处理时间已经被耗费掉。因此,我们需要一种机制,能够在问题发生的瞬间就进行记录,并保留这段时间内的相关数据,方便后期分析。这样,我们能够在帧的处理过程中,从头到尾进行回溯,查看导致某一帧处理时间异常的具体原因。

最简单的实现方式就是通过日志记录功能,保存每一帧消耗的时间数据。例如,可以记录每一帧的执行时间,然后在发生异常时,查看该帧的详细记录。这将帮助我们在后期能够通过查看记录,分析和定位问题。

我们可以从记录每一帧的调试计时器开始

在调试系统中,我们已经有了一些调试计时器(debug timers),这些计时器可以帮助我们记录程序中各个部分的执行时间。例如,使用计时器记录每个周期的时间,帮助我们跟踪程序不同模块的性能。如果我们每帧都记录这些计时器的结果,那么我们就能轻松识别哪些部分的执行时间超过了预期。

具体来说,我们可以在日志中记录每一帧的计时数据,比如记录每个计时器的数值。举个例子,假设我们有一个计时器 0,它的时间为 1 毫秒,计时器 1 的时间为 5 毫秒。那么在日志中,我们会看到类似这样的记录:对于计时器 0,执行时间为 1 毫秒;对于计时器 1,执行时间为 5 毫秒。通过这种方式,我们可以追踪到每一帧的性能数据,从而判断哪些部分的性能出现了异常,帮助后续的调试和优化工作。

简化我们调试计时器的使用

我们希望将当前的调试计时器系统进行抽象化,使其变得更加简洁易用。通过这种方式,我们可以方便地在代码的各个地方添加这些调试计时器,以便在不同的子系统甚至是子系统的一部分中都能记录这些性能数据。这样,在每一帧的日志中,我们不会存储过多的数据,保持日志的简洁性。比如每帧日志只需要存储大约16KB的调试计时器数据,这样就可以很轻松地在慢帧时回溯并查看哪些部分的执行时间较长,找出性能瓶颈。

这种方式的好处在于,我们可以轻松地在代码中各个地方插入这些调试计时器,帮助我们识别出哪些部分的代码是导致帧率下降的"罪魁祸首"。通过对每帧的性能数据进行记录和回溯,我们能够快速定位到性能问题的来源,从而进行优化。

我们可以结合日志和循环实时代码编辑回放系统

为了进一步提升调试服务的能力,可以考虑在日志系统的基础上添加更多的功能。例如,可以利用游戏中已有的回放功能。如果游戏能够存储初始状态并持续运行记录,我们就可以在遇到某一帧时,从游戏的起始状态开始,重现并执行直到达到该帧。这就类似于设置断点,可以在慢帧时进行调试,查看具体发生了什么,而不需要猜测某个函数中发生了什么问题。

这种方法非常有用,因为它允许我们直接查看导致慢帧的具体函数内容,而不是依赖于推测。然而,唯一的问题是,如果游戏已经运行了很长时间,比如30分钟或者10分钟,回放整个过程可能会非常耗时。因此,可以考虑实现检查点功能,每隔一段时间(比如30秒)就保存当前游戏状态的检查点。这样,在遇到问题时,可以从最近的检查点恢复,并快速跳过不必要的时间,直接进入出问题的帧进行调试。

这种技术能够帮助我们处理那些瞬时性问题,因为我们可以通过回溯到特定的检查点,再播放接下来的帧,轻松找到问题所在。这种方法可以大大提高调试效率,帮助发现那些难以捕捉到的瞬间性 bug。

这种方法的局限性 - 对于多线程问题并不特别有效

虽然实现回放和检查点功能对于调试游戏代码非常有效,但对于调试多线程问题来说,这种方法存在很大的局限性。多线程问题通常涉及操作系统的线程调度,而我们无法控制操作系统如何调度每个线程的运行时机。因此,无法简单地通过回放和检查点来重现死锁、内存覆盖、竞态条件等问题。要解决这些多线程问题,可能需要额外的工作,比如插入额外的代码来干预操作系统的线程调度,但这需要大量的投入。

因此,虽然回放和检查点技术对于调试非多线程相关的bug非常有用,但对于多线程相关的bug,特别是那些难以重现的bug,这种方法的效果就非常有限。除非这些多线程问题能够在特定的初始条件下可靠地重现,否则这种方法无法帮助我们准确地找到并调试这些问题。这是多线程调试的天然限制。

必需的组件:计数器日志、回放系统、内存消耗日志和图表

在调试系统的设计中,除了记录性能计数器日志和帧记录外,还需要考虑一些其他的调试工具。一个关键的方面是记录内存消耗和内存布局,这有助于查看内存的使用情况。通过这种方式,可以更清晰地了解内存的分配与使用,帮助识别可能的内存泄漏或不合理的内存分配模式。

此外,还需要关注对性能数据的捕获,比如慢帧的调试。这可以通过记录每一帧的性能信息,捕捉到其中的异常,从而能够快速发现性能瓶颈或其他问题。总的来说,这些工具的目的是提供更丰富的信息,帮助在不同维度上排查问题。

图表

在调试过程中,图形化的调试工具是非常重要的,尤其是在处理空间关系和几何问题时。比如在处理字体、向量或者物理引擎中的坐标时,仅仅依赖数字和数值是非常低效且容易出错的,尤其是对于那些非常细微或者复杂的空间问题。通过图形化的调试工具,可以直观地看到这些关系,使得调试变得更加高效。

比如在调试一个向量时,如果仅仅看数值(例如坐标x: 1.2, y: 3.4),即使经验丰富,也需要花费一些时间来判断这些数字在空间中的位置和关系。而如果有一个图形化的表示,直接通过一个图形展示向量的位置、方向以及与其他物体的关系,那么调试人员可以立刻发现问题,比如两个向量本应垂直,但却没有形成垂直关系。通过这种方式,调试过程可以大大缩短,减少不必要的思考时间。

图形化调试不仅帮助发现错误,也能帮助优化性能。通过将问题以图形化的方式展示出来,调试人员能更加直观地发现问题的根源,同时能够进行性能上的优化。许多复杂的游戏开发任务,包括空间计算、物理引擎、图形渲染等,都可以通过图形化的工具来帮助理解和调试,进而提高开发效率和游戏质量。

因此,图形化调试在游戏开发中的重要性不可忽视,特别是在涉及复杂空间关系的调试任务中,它能够极大地提高调试效率,帮助开发人员快速发现和解决问题。

我们希望所有调试子系统通过相同的日志进行

在调试过程中,图形化工具和性能数据通常是时间性强且交错发生的,所以不能将它们作为独立的系统来处理。比如,不应该将内存使用监控、性能计数器和图形化调试工具分开管理,因为许多难以察觉的错误往往在多个时间点和空间中交织发生。这些错误通常是跨越时间和空间的,而不是局部的。因此,我们不希望有多个分离的系统来处理这些不同类型的调试信息。

理想的做法是将所有这些信息整合到一个统一的日志系统中,便于追溯和调试。通过这种方式,当遇到问题时,开发人员可以轻松地查看前一帧的图形化信息、内存使用情况和性能计数,而不需要额外的调试工作或停止程序进入调试模式。这样的日志系统能够自动捕捉和存储所需的所有信息,包括图形化调试数据,使得开发人员能够在程序运行过程中轻松查看和分析问题。

具体来说,若通过这种日志系统捕捉信息,开发人员可以在遇到问题时直接回放特定的帧并检查其中的图形化数据,避免了手动停止程序并进入代码调试的繁琐流程。这种方法使得调试变得更加高效和流畅,从而减少了开发过程中的时间成本。

总的来说,将所有的调试信息整合到一个统一的系统中,能够提高调试效率并让问题的诊断变得更加直观,尤其是在处理时间性和空间性复杂的调试任务时。

我们希望避免在调试时需要更改代码...

假设我们有一些图形化工具,并且有一段代码,代码做的事情非常简单,例如:我们有一个简单的加法操作,A = B + C,其中B和C是三维向量。我们可能怀疑这个代码中可能存在一个bug,比如B向量的值可能被传递错误了,或者其他某些问题。

在这种情况下,最不希望做的事情是修改代码的结构。我不希望为了加入图形化调试工具而改变代码,尤其是不想修改函数的控制流。如果这个操作是在某个函数foo中进行的,我不想为这个函数增加额外的调试参数,也不希望在任何调用它的地方进行改动。我希望能够在不改动代码结构的情况下,直接在这段代码中进行调试标记和图形化显示。

例如,我可以在代码中直接添加类似draw(a)draw(b)draw(c)这样的调用来绘制变量的图形化表示,而无需对代码本身进行任何更改。这意味着,我可以随时在代码中加入这些标记,用来表示向量ABC等,帮助我可视化调试过程中的变量状态。

通过这种方式,我可以在不更改原始代码逻辑的情况下,通过简单的图形化工具来调试和检查问题,同时保证代码的结构和逻辑不被破坏。这种方法既方便又高效,可以让调试过程更加直观且易于执行。

...并且避免在完成后移除调试调用

我希望能够在代码中随时添加调试代码,而不需要担心以后需要移除它们。如果以后发现另一个bug,重新添加这些调试代码会很麻烦。所以,我希望调试代码始终保持在代码中,并且能够轻松地打开和关闭。这样,我可以将调试代码添加进去并忘记它,而不需要额外的工作,比如传递参数等复杂的操作。

另外,调试信息应该通过一个日志系统来管理。这样做的原因是,有时我无法提前知道哪些图形化调试信息会对排查问题有帮助,直到问题发生后才知道。为了避免这种情况,我可以记录下所有的图形化信息,并在日志中存储这些信息。即使问题发生了很久后,我也能通过日志回溯并找到相关的调试信息。

如果某个函数,比如foo,被调用了成千上万次,那么很难知道是哪一次调用出了问题。假设我们没有任何标识来帮助区分这些调用,而是只能知道有某个调用出错了。在这种情况下,如果我想找出哪个调用出了问题,就需要查看所有的图形化信息,但显然不能同时查看所有图形。所以,我希望所有的调试图形都记录到日志中,并且可以快速浏览这些信息,找到哪个图形出错。

一旦找到出错的图形,我可以查看相关的标记,并通过一些额外的信息(例如函数的调用索引)来帮助识别出错的调用。这样,我就能快速定位问题并解决它,而不需要手动查找每一个图形。这种方法可以显著提高调试的效率,帮助更快地找到问题根源。

我们调试系统的期望功能回顾

我希望所有的调试信息都能记录到一个日志中,这样我就不需要提前知道问题的根源在哪里。我希望能够非常快速和灵活地注释这些信息,而不需要改变任何代码结构。我希望能够在不增加过多成本的情况下,随时保留调试代码,这样就不用担心不断修改和删除调试代码的麻烦。调试系统应该尽可能简便,能够在特定区域打开和关闭,而不需要复杂的操作。

此外,我还希望能够以某种方式标注调试信息,帮助我快速识别和定位出错的部分。当需要查找特定问题时,这种标注机制能让问题变得容易发现。我觉得这些是调试系统的基础要求,未来可能会遇到更复杂和细微的挑战,我会根据实际情况逐步指出并解决这些问题。

我们是否需要调整/调试支持?

目前,我们已经有了循环实时代码编辑功能,这大大减少了我们对调参和调试的需求。实际上,如果没有这个功能,我们可能会需要额外的调参工具,但是现在通过代码中的修改,我们就能直接设置参数,并且每次修改参数值后,代码会自动重新加载,从而实现快速调试和调整。这种方式其实比传统的调试工具要好得多,因为它能够直接在代码中进行修改,且能灵活调整。

然而,对于没有支持实时代码编辑的游戏,通常会通过提供调参界面来辅助调试,这些界面会有一个控制面板,可以通过滑块、复选框等方式调整不同的参数值。这些界面主要是为了方便用户进行快速调试和调整,但对于我们来说,实时代码编辑本身就已经足够强大,不需要额外的界面工具。

尽管如此,仍然有一些情况可能需要额外的调试功能,比如为某些触发条件设置菜单,让我们可以快速跳转到游戏的某个特定区域或执行某些操作。尽管这种功能的实现方法不如代码中的调整那么直接和优雅,但在某些情况下可能会用到。我们可能会根据实际需要和时间安排,决定是否实现这种功能。

总的来说,虽然不一定需要额外的调参工具,但在一些特定的场景下,像快速跳转或某些触发条件的调试功能可能会有用。最终,我们会根据实际情况评估是否投入时间和精力来实现这些功能。

让我们开始构建调试系统

接下来,我们希望开始探索如何构建一个系统来实现这些目标。虽然时间有限,但我们可以开始着手实现这些想法。这些调试系统的实现并不复杂,基本上大家应该都能理解,调试系统的作用和如何使用它,也能理解为什么这些功能是有用的。最关键的是实现这些功能的细微差别,因为不同的实现方式会让调试系统的效果和实用性有很大的差距。

构建一个有效的调试系统,重要的并不仅仅是理解高层的概念,而是要精确地知道如何将这些功能集成,并且通过正确的方式去实现。高层的概念其实是非常明显的,大家都可以理解:目标是尽可能多地记录信息,并能够在需要时将这些信息以最有用的方式呈现出来。真正的挑战在于实现的细节,如何高效地编写代码,如何把这些功能做到尽可能好,这些都需要投入时间和精力。

因此,整个过程更多的是关于编程和实现的技巧,而不是概念本身的复杂度。

旧的调试周期计数器故意做得很差

这里故意让代码写得非常差,特别是在处理调试周期计数器的部分。这样做的目的是为了在之后构建实际的调试服务时,能展示出代码目前的不理想之处,方便对比之后改进的效果。比如在渲染器中,使用了调试周期计数器(如 BEGIN_TIMED_BLOCK)来记录调试数据。虽然我们已经做了全局活动处理,使得不需要传递调试服务指针,但仍然存在实际的成本,因为这些计数器是通过枚举类型定义的,使用时必须引用这些定义。

这种做法的一个缺点是,每次添加这类调试信息时,都需要实际花费时间来修改代码,这显然不符合我们追求便捷的目标。理想的做法是,我们希望能通过一行简单的代码就能记录下调试信息,比如通过 BEGIN_TIMED_BLOCKEND_TIMED_BLOCK,并且最好还能做到自动结束,而不需要手动写 end 语句。这样,我们只需关注核心功能,而不必再编写重复的开始和结束代码。

为此,我们可以通过使用C++的构造函数和析构函数来实现这个功能。具体做法是,利用构造函数和析构函数对调试块进行自动标记。这样,当程序进入一个调试块时,构造函数会自动开始计时,退出时,析构函数会自动结束计时。

为了不影响其他平台的编译,我们将这个功能封装到一个独立的文件中,命名为 game_debug.h。这样,即便其他平台的开发者在编译时,不会因为引入不兼容的代码而产生问题。

此外,也有一些开发者希望在Linux平台上能够使用调试周期计数器,我们可以在代码中添加一些条件编译的处理逻辑,以确保在不同的编译环境下也能正常工作。例如,通过添加针对Linux平台的特定编译指令,让调试工具能够兼容LLVM编译器。

总体而言,目标是使得调试过程更加简便,避免在每次调试时都需要手动添加和移除代码,让开发者可以专注于核心的调试任务,而不被繁琐的代码修改所困扰。

改进周期计数器的接口

之前的函数实现方式是,在函数开始时调用 BEGIN_TIMED_BLOCK(ID) 并传入一个 ID,然后在函数结束时,使用相同的 ID 调用 END_TIMED_BLOCK(ID),以标记调试时间段。这样,每次我们想要跟踪某个代码块的执行时间,都需要手动插入 BEGIN_TIMED_BLOCK(ID)END_TIMED_BLOCK(ID) 这两个调用。

然而,这种做法存在一定的问题。首先,开发者需要记住在正确的位置调用 END_TIMED_BLOCK(ID),如果忘记或者出错,可能会导致调试信息不准确。其次,每次添加新的调试记录都需要手动插入两行代码,不够简洁和高效。

理想情况下,我们希望能够只写一行代码,就能完成同样的功能。例如,我们希望能够只调用 BEGIN_TIMED_BLOCK(ID),然后不需要手动调用 END_TIMED_BLOCK(ID),而是让系统自动在作用域结束时执行 END_TIMED_BLOCK(ID),确保调试信息的完整性。这不仅减少了手动操作的成本,还降低了因人为疏忽导致的错误。

为了解决这个问题,我们可以利用 C++ 的构造函数和析构函数机制。具体而言,我们可以创建一个 TimeBlock 类,在构造函数中调用 BEGIN_TIMED_BLOCK(ID),在析构函数中调用 END_TIMED_BLOCK(ID)。这样,当 TimeBlock 的实例在进入作用域时,自动开始计时,而当实例超出作用域时,析构函数会自动执行 END_TIMED_BLOCK(ID),确保计时逻辑的完整性。

这种方法的优势在于:

  1. 代码简洁,开发者只需创建 TimeBlock 的实例,而不需要手动调用 END_TIMED_BLOCK(ID)
  2. 自动化管理作用域,减少人为错误的可能性。
  3. 适用于任何代码块,无需修改原有的函数逻辑。

这一优化方案可以显著提高调试系统的易用性,使得代码标注和性能分析更加直观和高效。

RAII

vscode 这个打开错误的地方会发出声音

Accessibility › Signal Options: Volume 是 VSCode 的一个无障碍(Accessibility)相关设置 ,用于调整 音量提示(audible signals)的音量

🔍 作用

这个设置 控制 VSCode 内部的提示音量,比如:

  • 错误提示(比如代码检查错误)
  • 通知音效(比如任务完成、构建失败等)
  • 屏幕阅读器的语音反馈

🔧 如何调整?

  1. 打开 VSCode 设置Ctrl + ,
  2. 搜索 signal options: volume
  3. 调节音量大小
    • 0(静音 ❌,完全关闭提示音)
    • 1-100(调整音量,100 = 最大音量 🔊)

🚀 相关设置

  • Accessibility › Signal Options: Enabled 👉 控制是否启用这些提示音
  • Accessibility › Screen Reader: Announce 👉 控制 VSCode 是否支持屏幕阅读器通知

如果你不想听到 VSCode 的提示音,可以把 signal options: volume 设为 0! 🎧🚀

利用构造函数/析构函数对来实现更好的接口

我们需要一个在代码块开始时调用的函数和一个在代码块结束时调用的函数,这正是我们所需要的两个部分。为了实现这一点,我们可以利用 C++ 的构造函数和析构函数的特性,虽然这种用法可能比较少见。我们可以定义一个 time_block 结构体,其中包含构造函数和析构函数。

C++ 试图形式化数据的初始化和销毁概念,尽管这个想法本身有些荒谬,因为数据本身并不具备这样的概念。但 C++ 设计者决定引入这一概念,允许程序员在创建 time_block 这样的对象时调用一个构造函数进行初始化,并在对象消失时自动调用析构函数完成清理工作。

在 C++ 中,如果在结构体内定义了函数,这些函数被称为成员函数 。成员函数不会占据结构体的存储空间,而只是与结构体关联的命名方式。当定义了成员函数后,它们默认会接收一个名为 this 的指针,该指针指向调用它的具体对象。这使得成员函数在访问结构体的成员变量时,无需显式传递对象的引用,而是可以直接使用变量名,C++ 编译器会自动推断出它属于当前对象。

为了实现 begin_time_blockend_time_block 这样的功能,我们可以定义一个 time_block 结构体,并在其构造函数中调用 begin_time_block,在析构函数中调用 end_time_block。这样,每当 time_block 变量在某个代码块中被声明时,构造函数会自动执行,调用 begin_time_block,而当该变量超出作用域(即代码块结束)时,析构函数会自动执行,调用 end_time_block,从而达到我们想要的效果。

实现这一逻辑的关键是:

  1. 构造函数 :在 time_block 变量被创建时调用 begin_time_block 并传入 ID。
  2. 析构函数 :在 time_block 变量超出作用域时调用 end_time_block 并使用存储的 ID。
  3. 成员变量 :需要在 time_block 结构体内存储 ID,以便在析构时仍然能访问它。

但是,我们遇到了一个问题:在析构函数执行时,如何知道 begin_time_block 传递的 ID?因为在 time_block 的构造函数中,我们可以直接传递 ID 进行 begin_time_block 调用,但在析构函数中,ID 可能已经无法直接获取。因此,我们需要在 time_block 结构体内部存储 ID ,以便在析构时能够正确传递给 end_time_block

此外,C++ 的 this 指针在成员函数中默认可用,使得我们可以直接访问 time_block 结构体的成员变量,而不需要手动传递 time_block 变量。这只是 C++ 为了引入面向对象编程概念而增加的语法糖,本质上只是对原有函数调用方式的重写,并没有真正增加新功能。

在实现过程中,我们还需要处理 ID 的传递问题。原先 begin_time_blockend_time_block 可能使用的是枚举值或其他形式的 ID,而我们需要确保 time_block 结构体能够正确存储并传递这些 ID。

在代码调整过程中,我们还发现 begin_time_blockend_time_block 的实现方式可能需要调整,以便更好地适应 time_block 结构体的使用方式。因此,我们创建了一个内部版本的 begin_time_blockend_time_block,直接接受 ID,而不是依赖外部上下文。

最终,我们的 time_block 结构体能够正确地在进入代码块时调用 begin_time_block,并在代码块结束时调用 end_time_block,自动管理时间跟踪的逻辑,实现了预期的功能。

我们可以通过一个示例来说明 time_block 结构体如何实现 begin_time_blockend_time_block 的自动调用。

原始做法

最开始,我们的代码可能是这样写的:

cpp 复制代码
void some_function() {
    begin_time_block(42);  // 记录开始时间
    // ... 执行某些代码 ...
    end_time_block(42);    // 记录结束时间
}

这种方法的问题是,我们必须手动调用 begin_time_blockend_time_block,如果函数中有多个 return 语句或者发生异常,可能会遗漏 end_time_block,导致计时不准确。


使用 time_block 结构体的改进方法

我们可以定义一个 time_block 结构体,在构造函数中调用 begin_time_block,在析构函数中调用 end_time_block

cpp 复制代码
struct time_block {
    int id;  // 存储时间块的 ID

    // 构造函数:对象创建时自动调用 begin_time_block
    time_block(int block_id) : id(block_id) {
        begin_time_block(id);
    }

    // 析构函数:对象销毁时自动调用 end_time_block
    ~time_block() {
        end_time_block(id);
    }
};

这样,每当 time_block 变量被创建时,构造函数会自动执行,调用 begin_time_block,而当变量超出作用域时,析构函数会自动执行,调用 end_time_block


改进后的代码

现在,我们可以这样写 some_function

cpp 复制代码
void some_function() {
    time_block tb(42);  // 进入作用域,构造函数自动调用 begin_time_block(42)

    // ... 执行某些代码 ...

}  // 作用域结束,析构函数自动调用 end_time_block(42)

some_function 运行时:

  1. 进入函数时,tb 变量被创建,构造函数自动调用 begin_time_block(42)
  2. 函数执行过程中,无需手动管理 begin_time_blockend_time_block
  3. tb 变量超出作用域时(无论函数如何返回),析构函数自动调用 end_time_block(42),确保计时正确。

好处

  1. 避免遗漏 end_time_block :即使函数因异常、return 提前结束等情况提前退出,析构函数仍然会执行 end_time_block,保证计时正确。
  2. 代码更简洁 :不需要手动写 begin_time_blockend_time_block,减少重复代码。
  3. 作用域管理清晰time_block 只在局部作用域内有效,保证了时间记录的范围准确无误。

这种方法利用了 C++ 的 RAII(资源获取即初始化)特性,确保资源在正确的时间被管理和释放,非常适用于自动管理时间块的开始和结束逻辑。

我们不应该需要存储任何值来进行每帧开始和结束时的配对调用

在代码实现过程中,我们需要在代码块的开始和结束时执行一对相同 ID 的函数调用。然而,C++ 并没有提供一个通用的特性来支持这种需求,而是通过构造函数和析构函数的机制来实现。这个机制本身并不是专门为这种用途设计的,所以使用起来会有一定的不便,但我们仍然可以利用它来达到我们的目的。

存储 ID 以便在构造和析构时使用

我们希望在 begin_time_blockend_time_block 之间传递相同的 ID,因此需要在 time_block 结构体中存储 ID。

示例:

cpp 复制代码
struct time_block {
    int id;  // 存储时间块的 ID

    // 构造函数,初始化 ID,并调用 begin_time_block
    time_block(int block_id) : id(block_id) {
        begin_time_block(id);
    }

    // 析构函数,在对象销毁时调用 end_time_block
    ~time_block() {
        end_time_block(id);
    }
};

这样,我们可以在代码块中自动开始和结束时间测量,而不必手动调用 begin_time_blockend_time_block


C++ 的局限性

尽管 C++ 提供了构造函数和析构函数的特性来支持作用域管理,但这种实现方式并不完美:

  1. 特性绑定过于严格

    C++ 仅支持构造和析构的配对调用,而不是一个通用的 "作用域开始 - 作用域结束" 机制。理想情况下,我们希望有更灵活的方式来管理作用域,而不是必须依赖构造和析构的绑定。

  2. 可读性和灵活性问题

    • 这种方式要求 time_block 对象必须是局部变量,不能动态分配,否则析构时机可能不正确。
    • 如果作用域划分复杂,可能会遇到一些意外的作用域结束情况,导致 end_time_block 过早或过晚执行。
  3. 无法完全泛化

    这个方法只能适用于 C++ 现有的作用域管理方式,不能完全适用于所有的代码场景,例如某些异步操作。


最终代码示例

假设 some_function 需要在执行过程中进行时间测量:

cpp 复制代码
void some_function() {
    time_block tb(42);  // 进入作用域,构造函数自动调用 begin_time_block(42)

    // 执行一些逻辑...

}  // 作用域结束,析构函数自动调用 end_time_block(42)

这样:

  • 进入 some_function 时,time_block 结构体自动调用 begin_time_block(42)
  • 退出 some_function 时,无论是正常执行结束还是因异常提前返回,都会自动调用 end_time_block(42),确保时间记录完整。

尽管 C++ 的实现方式存在一些局限,但通过这种方式,我们仍然可以高效地管理代码块的开始和结束操作,并减少手动调用的重复性。

在代码实现过程中,我们需要正确管理时间测量的起始和结束,以确保计算出的时间间隔是准确的。然而,在当前的实现中,存在一个问题:起始时间的变量可能会被破坏,导致最终计算的结果不正确。


解决起始时间存储的问题

我们希望在 begin_time_block 记录开始时间,并在 end_time_block 计算出所经过的周期数。但由于 start_cycle_count 变量是在 begin_time_block 作用域内声明的,在 end_time_block 时已经无法直接访问,因此需要将其存储在 time_block 结构体中,以便后续计算时使用。

调整数据存储

time_block 结构体中,除了存储 ID 之外,还需要存储 start_cycle_count,确保结束时能够正确计算时间差值:

cpp 复制代码
struct time_block {
    int id;
    uint64_t start_cycle_count;

    time_block(int block_id) : id(block_id), start_cycle_count(rdtsc()) {
        begin_time_block(id, start_cycle_count);
    }

    ~time_block() {
        end_time_block(id, start_cycle_count);
    }
};

begin_time_block 时,记录当前的 rdtsc() 值,并存储到 start_cycle_count 变量中。在 end_time_block 计算时,利用 rdtsc() 读取当前时间,并计算 start_cycle_count 到当前时间的差值,得到准确的执行周期。


调整 begin_time_blockend_time_block

begin_time_blockend_time_block 这两个函数的调用过程中,我们需要确保 start_cycle_countbegin_time_block 时正确存储,并且 end_time_block 时可以正确使用这个值进行计算。

修改 begin_time_block

begin_time_block 需要额外接收 start_cycle_count 作为参数:

cpp 复制代码
void begin_time_block(int id, uint64_t &start_cycle_count) {
    start_cycle_count = rdtsc();
    // 其他处理逻辑...
}

begin_time_block 内部,我们利用 rdtsc() 记录当前时间,并存储到 start_cycle_count 变量中。

修改 end_time_block

end_time_block 中,需要使用 start_cycle_count 计算时间差:

cpp 复制代码
void end_time_block(int id, uint64_t start_cycle_count) {
    uint64_t end_cycle_count = rdtsc();
    uint64_t elapsed_cycles = end_cycle_count - start_cycle_count;

    // 记录或输出计算出的时间
}

这样,end_time_block 就能正确计算 start_cycle_countend_cycle_count 之间的时间差,确保计算的正确性。


优化作用域管理

在实现过程中,我们还需要确保 time_block 作用域管理清晰,避免变量作用域问题。

我们可以调整 time_block 的管理方式,使其更加符合自动化管理的思路,例如:

cpp 复制代码
void some_function() {
    time_block tb(42);  // 进入作用域,自动记录起始时间

    // 执行函数内部逻辑...

}  // 作用域结束,自动计算并记录时间

这样,当 some_function 退出时,无论是正常返回还是异常退出,time_block 的析构函数都会自动调用 end_time_block,确保正确计算时间间隔。


修正潜在的问题

在实现过程中,还需要注意:

  1. 避免不必要的栈变量创建
    begin_time_block 过程中,不应该重复创建 start_cycle_count,而是应该传递引用,确保数据一致。
  2. 调整 begin_time_blockend_time_block 的参数传递
    需要确保 start_cycle_countbegin_time_blockend_time_block 之间正确传递,并且不会因为作用域问题导致数据丢失。
  3. 优化宏的使用
    如果使用宏来简化 begin_time_blockend_time_block,需要确保宏的参数不会引入新的作用域问题。

最终优化

综合以上问题,我们可以整理出最终的代码结构:

cpp 复制代码
struct time_block {
    int id;
    uint64_t start_cycle_count;

    time_block(int block_id) : id(block_id), start_cycle_count(0) {
        begin_time_block(id, start_cycle_count);
    }

    ~time_block() {
        end_time_block(id, start_cycle_count);
    }
};

void some_function() {
    time_block tb(42);  // 进入作用域,自动记录时间

    // 处理逻辑...

}  // 退出作用域,自动计算执行时间

这样,我们在 some_function 内部创建 time_block 对象,即可自动完成时间测量,避免了手动调用 begin_time_blockend_time_block 的繁琐操作,并且保证了 start_cycle_count 的正确性。


总结

  1. 存储 start_cycle_count 避免作用域丢失
    • start_cycle_count 需要在 begin_time_block 中初始化,并传递给 end_time_block 计算。
  2. 优化 time_block 结构体
    • time_block 负责 begin_time_blockend_time_block 的自动管理,确保作用域正确。
  3. 调整 begin_time_blockend_time_block 的参数
    • 传递 start_cycle_count 避免变量作用域问题,确保时间计算的准确性。
  4. 改进作用域管理
    • 使用 time_block 结构体的构造和析构函数,自动管理时间测量过程。

这样,我们就能够确保时间测量的准确性,同时提升代码的清晰度和可维护性。

回顾新周期计数器接口的内部工作

最终,实现这一功能的方法其实非常简单。我们需要使用一个结构体,在其构造函数和析构函数中分别调用时间测量的起始和结束函数,这样就可以确保时间测量在代码块的开始和结束时自动进行。


使用结构体管理时间测量

我们定义一个 time_block 结构体,并在其构造函数和析构函数中分别调用 begin_time_blockend_time_block

cpp 复制代码
struct time_block {
    int id;
    uint64_t start_cycle_count;

    time_block(int block_id) : id(block_id), start_cycle_count(rdtsc()) {
        begin_time_block(id, start_cycle_count);
    }

    ~time_block() {
        end_time_block(id, start_cycle_count);
    }
};
  1. 构造函数 time_block(int block_id)

    • time_block 对象在栈上创建时,构造函数会自动执行。
    • 在构造函数中,使用 rdtsc() 记录当前的时间戳,并调用 begin_time_block 进行时间记录。
  2. 析构函数 ~time_block()

    • time_block 对象超出作用域(即代码块结束)时,析构函数自动执行。
    • 在析构函数中,调用 end_time_block 计算经过的时间,确保测量的完整性。

通过这种方式,我们可以 自动管理时间测量的开始和结束 ,无需手动调用 begin_time_blockend_time_block,从而简化代码逻辑。


使用 time_block 进行自动化时间测量

使用 time_block 之后,代码的可读性和维护性都得到了提升。例如:

cpp 复制代码
void some_function() {
    time_block tb(42);  // 进入作用域,自动记录时间

    // 执行函数内部逻辑...

}  // 退出作用域,自动计算执行时间

some_function 内部创建 time_block 对象后:

  1. 进入代码块时 ,构造函数会自动执行,记录 start_cycle_count
  2. 退出代码块时 ,析构函数自动执行,计算 start_cycle_count 到当前时间的差值。

这样,我们就不需要手动调用 begin_time_blockend_time_block,避免了忘记调用或调用顺序错误的风险。


宏的使用问题

之前的代码使用了一些宏来处理 begin_time_blockend_time_block,但 C++ 的宏本质上并不智能,导致了很多额外的调整工作。例如:

  • 需要确保 start_cycle_countbegin_time_block 作用域内存储,并且 end_time_block 仍然能够访问它。
  • 由于宏的限制,不得不进行一些 冗余代码调整,以确保变量作用域不会被破坏。

相比之下,使用 time_block 结构体的方式更加直观、清晰,避免了宏所带来的维护问题。


优化数据存储

time_block 结构体的实现中,我们利用结构体本身存储 start_cycle_count,确保进入和退出代码块时能够传递相同的 start_cycle_count 值:

cpp 复制代码
struct time_block {
    int id;
    uint64_t start_cycle_count;

    time_block(int block_id) : id(block_id), start_cycle_count(rdtsc()) {
        begin_time_block(id, start_cycle_count);
    }

    ~time_block() {
        end_time_block(id, start_cycle_count);
    }
};

这一方式虽然在 C++ 中 显式存储了一些额外的数据 ,但在编译优化时,编译器可以识别这些变量的作用域,并进行优化,最终生成的汇编代码可能不会存储这些变量,而是直接在寄存器中传递它们,从而避免额外的性能开销。


总结

  1. 使用 time_block 结构体自动管理时间测量

    • 通过构造函数和析构函数,自动调用 begin_time_blockend_time_block,简化代码逻辑。
  2. 避免使用复杂的宏

    • 由于 C++ 宏的限制,原本的宏方式导致了一些作用域管理上的问题,而 time_block 结构体的方式更加直观易读。
  3. 优化数据存储

    • 通过 time_block 结构体存储 start_cycle_count,确保时间测量的正确性,同时编译器可以优化掉不必要的存储开销。

最终,这种方式 既保证了时间测量的准确性,又提升了代码的清晰度和可维护性

是否有方法确定启用调试时的开销?(或者是否存在调试级别的概念?)

关于调试代码带来的性能开销,通常有一种方法来确定它的影响,那就是将调试代码完全禁用并进行对比测试。对于某些项目,尤其是游戏开发中,我们通常会使用条件编译来控制调试信息的启用与禁用。

调试代码的控制

  1. 条件编译 :在代码中,使用预处理指令如 #define 来控制调试代码是否启用。例如,定义 GAME_SLOWGAME_INTERNAL 等宏,以便决定是否包含调试相关的功能。

    • GAME_SLOW:包括所有调试功能,适用于需要调试的开发环境。
    • GAME_INTERNAL:包括开发者注释和相关信息,主要用于开发期间的内部测试。
  2. 测试调试开销:如果想要评估调试代码的开销,可以通过将调试代码编译掉来对比性能。例如,可以在发布版本中完全移除调试代码,从而确保发布版本不受调试信息带来的性能影响。

避免调试代码影响最终版本

调试代码虽然在开发过程中非常有用,但它会增加一定的性能开销。在实际发布产品,特别是针对低性能设备的版本时,我们不希望这些额外的开销影响最终用户的体验。因此,通过条件编译,可以轻松地切换掉调试功能,确保最终版本的执行效率。

通过这种方式,开发人员可以在开发阶段尽可能详细地进行调试,而在发布阶段,通过简单的切换,避免调试信息对性能的影响,从而提供更高效的最终产品。

为什么不使用简单的模板?这样可以去除存储ID,例如:template struct timed_block {};

使用模板来移除存储 ID 的想法,表面上看似是一种优化方式,但实际上并没有实际的优势。虽然模板是在 C++ 语言发展初期添加的功能,可以用来进行泛型编程和类型推导,但在这个场景中,并不需要这种复杂的功能。

为何不使用模板?

  1. ID 的常量性质:在这个特定的实现中,ID 是一个常量值。对于编译器来说,ID 是不变的,因此在优化过程中,编译器会自动将其消除。这意味着即使通过模板来处理,也不会对性能产生实质性影响,因为编译器会智能地优化掉这些不需要的代码。

  2. 没有实际成本:即使使用模板,最终的结果也不会对性能产生负担。因此,使用模板只是引入了不必要的复杂性。模板会增加编译时的复杂度,而且会引入更多语言特性,但对于这个问题来说,并不需要这些额外的特性。

  3. 避免不必要的复杂度:使用模板可能看起来是一个通用的解决方案,但在此情境下,其实并不会带来任何真正的优化,反而可能使得代码更加复杂,且对编译器优化没有实质影响。

总之,虽然模板可以解决某些编程问题,但在这种情况下,使用模板会增加不必要的语言特性复杂度,且不会带来实际的性能提升。因此,选择保留当前的实现方式,而不是引入模板,是一种更为简洁且高效的选择。

你是否同意测试驱动开发和"先写使用代码"的做法有相似之处?

在讨论测试驱动开发(TDD)和先编写使用代码的做法时,确实存在一定的相似性,但它们之间也有明显的区别。

相似性:

  1. 目标导向: 两者都在开发过程中以某种形式"反向"推动代码的编写。TDD中,测试代码先于实现代码编写,用测试来驱动开发,而在先写使用代码的情况下,使用代码通常先行,推动实现代码的开发。

  2. 提前规划: 无论是TDD还是先写使用代码,开发者都在编写代码之前对其功能进行思考。这种思考通常会在一定程度上影响后续的开发流程。

主要区别:

  1. 目的不同:

    • TDD:测试驱动开发的核心目的是通过编写测试来驱动开发过程,关注的是测试覆盖面、边界情况和代码的健壮性。TDD要求开发者从一开始就考虑如何保证代码的正确性,特别是要发现可能的边缘情况。
    • 先写使用代码:这种方法更侧重于写出能够正常工作的使用代码。其目的是让代码实现尽可能简单易用,并且关注的是功能实现和使用的便捷性,而不是测试或验证的角度。
  2. 心理状态的不同:

    • TDD:在写代码时,开发者的心态是为了确保代码能满足所有的测试要求,尤其是边界情况、极端输入和各种可能的测试场景。这种方式强调测试和验证。
    • 先写使用代码:此时开发者的关注点更多是在实际使用上,目的是找到最便捷、最合理的方式来使用代码,通常会偏向于"怎么用"而非"怎么测试"或"怎么确保所有情况都被涵盖"。

总结:

虽然测试驱动开发和先写使用代码的方式在某些方面相似,都涉及到某种程度的反向开发,但它们的核心目标和实施策略有显著的区别。TDD更注重代码的正确性和全面性,而先写使用代码则更注重代码的使用性和实际工作功能。因此,理解这两者的差异非常重要,以确保选择适合当前项目需求的开发方法。

你会考虑在游戏开发中使用函数式编程吗?

在游戏开发中使用函数式编程是完全可行的,但并不意味着要完全采用函数式编程的方式。实际上,函数式编程在某些特定的任务中非常有用,尤其是那些不需要大量访问状态的部分。在这些情况下,采用函数式编程可以避免因无法轻易观察的状态变化而导致的错误。

函数式编程的一个重要优势是,能够使代码更清晰,所有的输入和输出都非常明确,且没有副作用。这样能够减少潜在的错误,尤其是那些由不可见的、隐式的状态变化引发的错误。对于一些需要明确输入输出且不依赖于外部状态的功能,使用函数式编程可以使代码更简洁、更容易维护。

然而,虽然函数式编程有其优势,但并不意味着游戏开发应该完全依赖它。写一个完全基于函数式编程的游戏并不是一个好主意,因为不同的编程风格适合不同的任务。函数式编程确实非常适合某些情况,但并不是每种任务都适合用函数式编程来解决。在许多情况下,程序式编程(比如面向过程的编程)可能更合适。

因此,最好的做法是根据不同任务的需求选择适合的编程风格。了解每种编程风格的优缺点,并在适当的时机选择合适的工具,这样能够提高开发效率,并减少错误的发生。

当你没有明确设置 StartCycleCount = StartCycleCountInit 或类似的操作时,StartCycleCount 是如何在结构体中存储的?

在这种情况下,StartCycleCount 变量实际上会被存储在结构体中。虽然在某些地方没有显式地将 StartCycleCount 设置为某个值,比如通过 start 来进行计数,但它在 begin time block 中是通过宏展开的方式进行初始化的。具体来说,begin time block 会展开成一个实际的代码,这段代码会初始化 StartCycleCount,并将其设置为当前的时钟值(通过 DTSC 获取)。因此,StartCycleCount 变量在代码执行时会获得当前时钟的值,而这就是它被存储的方式。

总的来说,虽然没有直接显式地给 StartCycleCount 赋值,但它还是会在宏展开时被初始化,确保在使用时能够正确地存储当前时钟的值。这种做法是为了在后续的操作中能够追踪和计算时间差。

cpp 复制代码
macro BEGIN_TIMED_BLOCK_
provided by "game_platform.h"

#define BEGIN_TIMED_BLOCK_(StartCycleCount) StartCycleCount = __rdtsc();

// Expands to
StartCycleCount = __rdtsc();
cpp 复制代码
macro END_TIMED_BLOCK_
provided by "game_platform.h"

#define END_TIMED_BLOCK_(StartCycleCount, ID)                                  \
    DebugGlobalMemory->Counters[ID].CycleCount += __rdtsc() - StartCycleCount; \
    ++DebugGlobalMemory->Counters[ID].HitCount;

// Expands to
DebugGlobalMemory->Counters[ID].CycleCount += __rdtsc() - StartCycleCount;
++DebugGlobalMemory->Counters[ID].HitCount;

你会遇到海森堡效应的错误吗?即只有在没有运行调试代码时,错误才会发生?

确实,存在所谓的"海森堡 bug"(Heisenbug),这种 bug 只会在没有启用调试代码的情况下出现。对于这种 bug,基本上没有什么办法能够避免,因为它的出现通常和调试代码的影响有关。至于这个项目是否会出现这种 bug,谁也无法预测。但可以肯定的是,在一个项目中,几乎不可能完全避免这种难以发现的 bug。

调试服务并不能保证你能够轻松地找到所有 bug,也不能确保所有 bug 都能迅速被定位到。事实上,这是一种理想化的想法,实际上并不现实。因此,调试服务确实有可能帮助你发现一些 bug,但对于那些只有在调试代码关闭时才会发生的 bug,调试代码反而无法帮助找到,最终还需要依靠传统的调试手段来解决。

不过,最重要的一点是,调试服务的目的并不是为了处理每一个 bug,而是为了提高整体的效率。虽然会有一些 bug 无法通过调试服务快速定位,但大多数情况下,调试服务能够帮助更快地找到问题,并节省大量的时间。因此,即使面对个别无法调试的 bug,调试服务仍然是非常有价值的,能大大提高开发效率。

"Heisenbug" 这个术语来源于物理学家海森堡(Werner Heisenberg)的"不确定性原理"。海森堡的不确定性原理指出,在量子力学中,某些物理量的精确值无法同时被测量,例如粒子的位置和速度。简而言之,观察过程本身会干扰被观察的事物。

在软件开发中,"Heisenbug"指的是那些在调试时无法复现,或只有在调试代码被关闭时才会发生的 bug。换句话说,调试器的存在改变了程序的行为,导致这些 bug 只在特定的条件下才会出现,调试器的干预本身就"影响"了 bug 的表现方式,因此很难找到它们。

这种命名方式的核心意思是,正如海森堡的不确定性原理一样,程序中的某些 bug 只有在你尝试去观察它时,它们的表现就发生了变化,导致它们变得难以捕捉和重现。

所以析构函数在它超出作用域时会被调用,对吧?β

在C++中,结构体的析构函数会在对象超出作用域时被自动调用。C++对析构函数的调用顺序有着非常严格和复杂的规则,但一般情况下,可以遵循一个简单的经验法则:析构函数会按照对象构造的逆序被调用。

例如,如果一个函数中定义了多个对象,像是 timedBlock a, b, c, d,它们的构造函数会按顺序从 ad 依次被调用(即 a 先构造,b 接着构造,依此类推)。而当这些对象超出作用域时,它们的析构函数则会以相反的顺序被调用,即从 da。这种逆序调用是为了避免在析构时出现引用已被销毁对象的情况。例如,如果 a 先被构造,而 b 需要引用 a,那么 a 的析构函数如果先被调用,就可能会导致 b 在析构时引用到已销毁的对象,造成潜在错误。

总结来说,C++确保析构函数的调用顺序是与构造函数相反的,从而避免引用已销毁对象的问题。尽管C++的规则可能非常复杂,但只要掌握这个简单的经验法则,就能帮助开发者处理大多数常见的情况,避免一些潜在的问题。

相关推荐
Chef_Chen2 小时前
从0开始学习R语言--Day18--分类变量关联性检验
学习
键盘敲没电2 小时前
【IOS】GCD学习
学习·ios·objective-c·xcode
海的诗篇_3 小时前
前端开发面试题总结-JavaScript篇(一)
开发语言·前端·javascript·学习·面试
AgilityBaby3 小时前
UE5 2D角色PaperZD插件动画状态机学习笔记
笔记·学习·ue5
AgilityBaby3 小时前
UE5 创建2D角色帧动画学习笔记
笔记·学习·ue5
武昌库里写JAVA4 小时前
iview Switch Tabs TabPane 使用提示Maximum call stack size exceeded堆栈溢出
java·开发语言·spring boot·学习·课程设计
一弓虽5 小时前
git 学习
git·学习
Moonnnn.7 小时前
【单片机期末】串行口循环缓冲区发送
笔记·单片机·嵌入式硬件·学习
viperrrrrrrrrr78 小时前
大数据学习(131)-Hive数据分析函数总结
大数据·hive·学习
fen_fen8 小时前
学习笔记(26):线性代数-张量的降维求和,简单示例
笔记·学习·算法