当前位置: 首页 > article >正文

游戏引擎学习第177天

仓库:https://gitee.com/mrxiao_com/2d_game_4

今日计划

调试代码有时可能会非常困难,尤其是在面对那些难以发现的 bug 时。显然,调试工具是其中一个非常重要的工具,但在游戏开发中,另一个非常常见的工具就是自定义的调试工具,这些工具直接内嵌在游戏中,帮助我们更有效地定位 bug,或者帮助我们在第一次编写代码时就发现 bug。我们现在正在 game Hero 中做的就是这件事,创建一些可以集成到代码中的工具,帮助我们更有效地发现 bug,并且让 bug 更容易暴露出来。在某些情况下,bug 可能会隐藏或者潜伏很久,我们通过这些工具让它们变得更加显而易见,进而更容易找到。

昨天我们刚刚开始了这个任务,我做了一个简单的概述,并且开始了编程,但是进展不大。所以今天将是我们第一次全面开始这项工作的编码。我们有很多事情要做,因此我们现在就可以开始了。

回顾之前完成的内容

昨天停在了开始创建一个游戏调试文件(Debug.h)的地方,目的是将所有调试相关的代码放在一个单独的文件里,因为调试代码会非常多,我希望将它与主代码分开,这样我们就能更清楚地区分哪些代码是调试用的,哪些是主逻辑代码。通过这种方式,也可以更方便地启用或禁用调试功能。因此,我将所有的调试代码都放进这个文件里,以保持代码的整洁和分离。

我们首先开始着手调试周期计数器(DebugCycleCounter)。这是因为我们之前已经实现了这些功能,因此我想通过将这些调试计数器变得更加可用,作为一个练习来推动这部分功能的发展。所以我正在处理中这个任务。

到目前为止,我们已经完成了这个简单的步骤:通过宏替换,避免每次都需要手动写开始和结束的标志,而是通过一个结构体,它有构造函数和析构函数,这样编译器就会自动在代码块的开始和结束处插入构造函数和析构函数,从而实现计时器的自动开始和结束。这是一个很基础的做法,但它并没有达到我希望的程度,实际上离我想要的简便性还有很大的差距。

为什么没有达到这个目标呢?首先,尽管我们将原本的开始和结束部分通过 TIMED_BLOCK 宏替换掉,减少了很多不必要的代码,但仍然没有解决计时器系统的易用性问题。理想情况下,我希望每次插入一个计时器的地方,它的开销是尽可能小的,但实际上现在的实现并没有达到这一点。
在这里插入图片描述

TIMED_BLOCK 每次调用会构造一个对象
在这里插入图片描述

TIMED_BLOCK 应该更易于使用

我们希望能够轻松地对新函数进行计时,比如说对一个“绘制位图”(DrawBitMap)的函数进行计时。理想情况下,我只需要在代码中写下“DrawBitMap”,然后编译,这样就能自动创建一个新的时间块,完成计时功能。然而,之前构建的系统并没有这样简单,它会报错。原因是,函数“DrawBitMap”需要对应一个在调试系统中定义的ID,而我们没有做这一步。这样一来,程序员在思考其他逻辑时,会被迫停下来,去记得如何添加一个新的调试计数器,这种中断思路的方式并不是我们想要的。

理想情况下,我希望能够直接把计时功能添加到代码中,就像使用断言(assert)一样。比如我可以简单地写“Assert(Buffer != 0),这样就添加了调试代码,并且它会自动工作,无需额外思考代码结构,也不需要跳转到其他文件。这就是我希望块级计时(block timing)最终能达到的效果。所以问题是,如何才能做到这一点呢?
在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

计时一个代码块应该是一个低开销的操作

在进行代码块计时时,有几个重要因素需要考虑。首先,计时操作本身是性能敏感的。我们之所以要进行计时,目的是了解某段代码的性能。因此,最重要的是,不能在计时操作中引入过多不可预测的开销。如果计时操作非常昂贵,它不仅会限制我们能够使用它的次数,还会影响性能。更重要的是,计时操作越是复杂,它对被测代码的性能影响越大。

举个例子,如果我们要查看“DrawBitMap”函数的性能,假设我们在计时时插入了大量复杂操作,例如遍历数据结构、复制字符串等,这将导致对缓存状态的影响,从而可能影响“DrawBitMap”函数的实际表现。因此,除了确保计时系统易于使用之外,还必须确保它非常高效,以避免在计时过程中对计算机的状态产生过多影响。减少这种“海森堡效应”(Heisenberg Effect)的影响,能够确保计时信息更准确、更有用。

摆脱调试计数器 ID

首先,我们要解决的问题是如何去掉我们的ID。实际上,在C++中,有一种相对简单的方法可以获取有关时间块的位置的信息,这样我们就不需要引入额外的枚举来告诉我们时间块的位置了。这就是我们之前已经见过的预处理指令。实际上,我现在想了想,也许我们从未使用过这些指令,对吧?可能我们还没有用过这些指令。那么,这次正好是第一次使用它们,完全没问题,接下来我们就来看看它们是什么。

预处理指令

如果我想在C程序的任何时候查看预处理器(负责处理 #include#define 等操作的部分),我可以在代码的任何位置,像是在 GameUpdateAndRender 或其他地方,使用一组指令。这些指令包括 __FILE____FUNCTION____LINE__。这些指令会扩展为它们对应的内容。例如,使用 __FILE__ 就可以获取当前正在编译的文件名,__FUNCTION__ 获取当前函数的名称,__LINE__ 获取当前行号。

有时候,这些值可能会带上一些字符串操作的麻烦,比如一些强制类型转换的问题。部分编译器(例如LLVM)可能会对这些值发出警告,要求进行类型转换。尽管如此,这些问题通常不会造成太大的困扰,因为在Windows平台上,这一过程还比较正常。不过,如果跨平台开发,可能需要注意这些问题。

我可以在 GameUpdateAndRender 函数处设置断点,然后运行程序。此时,调试器显示的变量将包含我请求的信息。编译器在编译时,会将这些字符串信息直接嵌入到代码中。所以,编译器并不需要在执行时动态获取文件名或函数名,它只是将这些信息作为常量存储在代码中,并且指向可执行文件的数据段。这使得我们不需要手动管理这些常量,也不必担心它们会过时。举个例子,当程序中添加新行时,行号会自动更新,不必担心手动修改行号后出现的错误。总之,这些预处理指令非常方便,能够自动确保文件名、函数名和行号等信息的准确性。
在这里插入图片描述

__COUNTER__ 预处理指令

还有一个预处理器指令,我坦白说不太记得它是否在所有C编译器中都被允许,但现在我认为大部分编译器应该都支持它。我会展示给你看,虽然我不确定我们是否一定会用到它,但它在某些情况下可能会非常有用。虽然我无法预测我们是否会遇到这种情况,但我还是想让你知道这个指令的存在。
在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

考虑 MSVC 的 genderα术语

MSVC支持这个功能,我很确定它是支持的,尽管不确定它的术语,Visual Studio可能是“它”会比较合适。现在我会通过代码演示,可以看到每个地方现在都有一个值,这个值是文件中__COUNTER__出现的次数。

其实做这个的意义并不大,唯一的原因是理论上如果你尝试使用像行号这样的方式来区分宏中的某些东西时,如果两个宏出现在同一行,它们会有相同的数字。因此,__COUNTER__在调试情况下特别有用,因为如果遇到多个宏出现在同一行并且需要用某种预处理器数字来区分它们,那么就有一种可靠的方式来实现这一点。所以,这个特性在某些情况下是很方便的。

预处理指令在调试代码中频繁使用

在这个过程中,接下来要做的是使用一些预处理器指令来帮助自动化一些操作。通常,这些功能在生产环境中并不需要频繁使用,但在调试和增强 C++ 代码时,它们非常有用。调试时,往往会用到这些工具来增强代码的可操作性和可追踪性。

例如,在调试时,可能会使用类似“时间块”的调试工具来帮助分析特定函数的执行时间。在本例中,通过引入一个时间块,我们能够方便地监测像 drawbitmap 这样的函数的性能。预处理器指令能自动处理文件名、行号等信息,从而不需要手动添加这些内容,从而避免出错和繁琐的工作。

这些工具和技巧主要用于调试环境,它们帮助程序员更高效地管理代码,并在调试时提供有用的信息。而在正式的生产代码中,通常不会频繁使用这些功能,除非在某些特殊情况下需要它们。

摆脱 TIMED_BLOCK 的参数

在这个过程中,目标是能够简化时间块的使用,使其更加自动化和方便。我们希望在调用 TIMED_BLOCK 时,不需要传递任何额外的信息,也不需要关心细节,只要能够自动记录和显示时间,且能够在以后查看是哪一段代码被计时。简而言之,只需要一个简洁的接口来计时,而无需手动添加额外的标识符或信息。

为了实现这一点,首先,TIMED_BLOCK 的实例化方式保持不变。问题在于,当没有传递任何 ID 或标识时,如何区分不同的时间块。在这种情况下,最佳的做法是利用代码中的行号来解决这个问题。通过将行号作为唯一标识,可以确保即使某一代码块多次出现,每次的时间记录也是独立且不冲突的。这样,时间块的标识就变得不依赖于程序员输入的额外信息,而是自动生成的。

这个方法的优势在于,它简化了时间块的使用,同时通过行号提供了区分不同时间块的有效方式。

避免使用预定义值同时仍能描述 TIMED_BLOCK 调用的位置

在这段代码中,目标是改善时间块(TIMED_BLOCK)系统,使其更加自动化且便于调试。关键的问题是,无法直接使用预定义的计时器标识符(如枚举类型的 DebugCycleCounter),因为这些预定义的标识符限制了灵活性。因此,需要找到一种替代方法来标识每个时间块,同时避免使用过于繁琐的预定义值。

为了解决这个问题,关键是使用能自动提供位置信息的预处理指令(例如,文件名、行号和函数名)。这样,在时间记录时,可以在 TIMED_BLOCK 中传递这些信息,确保即使没有显式定义标识符,也能准确区分不同的时间块。通过结合文件名和行号,即使多个时间块出现在同一行代码中,也能通过这些唯一的标识符进行区分。这个方法可以消除潜在的冲突,并且通过输出函数名、文件名和行号,调试人员可以轻松地定位代码中的具体位置。

这不仅能帮助准确定位性能瓶颈,还能使调试过程更加清晰简便。例如,调试输出中会显示“DrawBitMap”以及具体的文件和行号信息,使得程序员不必记住或查找具体代码位置,直接从调试信息中获得足够的上下文。

总之,这种方法使得调试过程更为高效和透明,特别是在没有预定义标识符的情况下,能够通过自动化的位置信息帮助开发人员准确识别每个计时块的位置。
在这里插入图片描述

将文件名和行号映射到 ID

在当前的情况下,尽管已经收集了足够的信息来执行任务,但仍然缺乏一种方法来完成之前做过的工作。因为为了完成相同的工作,需要一种方法将文件名和行号映射到一个唯一的 ID。然而,目前并没有现成的方式来实现这种映射。因此,需要开始思考如何将文件名和行号转换为一个唯一的 ID 以进行后续操作。

我们可以使用某种映射策略,但对于调试日志系统来说,这太复杂了

有很多方法可以实现文件名和行号的映射,比如创建一个映射表,利用二叉树,或者甚至使用暴力搜索。可以通过查找文件名和行号的组合,找到对应的 ID,若不存在,则新建一条记录,然后继续处理。这是我们熟悉的做法,只需要实现一个简单的查找 ID 的功能,传入文件名和行号就能返回相应的 ID。

然而,问题在于如果我们采用这些方法,可能会导致性能上的问题。因为每次都需要进行二叉树遍历或者内存列表查找,这样会增加额外的开销,这正是我们不希望出现的情况。虽然这样做有可能是最合适的选择,但我们也需要考虑是否有更高效的方法,避免不必要的性能损失。

#pragma once
#include "game_platform.h"
#define TIMED_BLOCK(ID) timed_block TimedBlock##__LINE__(__FILE__, __LINE__, __FUNCTION__);

struct timed_block {
    uint64 StartCycleCount;
    uint32 ID;
    timed_block(const char* FileName, int LineNumber, const char* FunctionName) {
        ID = FindIDFromFileNameLineNumber(FileName,LineNumber);
        BEGIN_TIMED_BLOCK_(StartCycleCount);
    }
    ~timed_block() {  //
        END_TIMED_BLOCK_(StartCycleCount, ID);
    }
};

在这里插入图片描述

另一种方法:避免在打印时查找文件名和行号

另一种方法是将文件名和行号存储在一个结构体中,而不是每次都进行查找。具体来说,可以创建一个定时器记录(timer record),它包含线程索引、行号、文件名、函数名以及时间戳等信息。每次记录时,只需将这个结构体写出,而不需要在每次操作时进行查找。

这样做的优势是,只有在打印这些信息时才需要使用它,而不需要频繁地进行查找操作。这减少了性能开销,避免了每次都对文件名和行号进行查找的操作。如果需要,可以采取一些优化措施,例如使用非临时存储,进一步减少对缓存的污染。通过这种方式,可以有效地优化性能,同时保留必要的调试信息。
在这里插入图片描述

根据我们记录的信息量,这种方法可能会增加写入带宽的压力

提到这个选项是因为,如果计划调用定时器的次数比较少(例如几次),那么这种做法是合适的。如果计划调用几百万次,那么每次写入可能会增加大量的带宽使用,影响性能。因此,很难确定哪种方法更加高效。

值得注意的是,虽然结构体中存储了时间戳、文件名、行号等信息,可以设法减少存储空间,但实际上,每个时间戳需要8个字节,指针的存储空间也无法轻易减少。C++的字符串表使用指针,不能像某些语言那样直接使用8位值,这使得优化变得复杂。

最终,每次记录都需要写入32字节(每个字段8字节),这意味着每次进入和退出时会写入64字节的数据。虽然这比较大,但仍然是一个平衡的选择。

关于哪种方式更优,我不确定现代机器的性能测试结果。不过,如果定时块的数量较少,那么使用日志版本的方式会更加合适。总之,我决定使用日志版本的方法。
在这里插入图片描述

我们首先尝试另一种方法

我觉得日志版本对我来说更有趣,它也具有一些好的特性,可以让我们稍后做一些事情,而如果不使用日志版本的话,这些事情在多线程上下文中会变得更加困难。不过,这其实有点夸大其词,可能没那么严重。

总的来说,这些操作的目的是补偿C++作为语言的不足,C++本身在处理这类问题时并不特别方便。一个更理想的做法是能够生成类似于我们现在所做的那种东西。你希望能够将一个数据集合并,使用索引来对应代码中的位置,这些位置指示了定时块出现的地方,然后将这些数字有效地嵌入到记录中。

我们可以利用 __COUNTER__,因为我们是单一编译单元构建!

我现在突然有个想法,这是我以前没有过的想法。因为我们只在一个编译单元中工作,严格来说,我们有两个编译单元,但即便如此,难道我们可以直接用计数器来做那个列表的合并吗?感觉似乎是可以的。

快速测试 __COUNTER__ 方法

我们可以通过利用计数器来绕过整个问题,这个方法是我之前没有想到的,因为我从未真正考虑过如何在单一编译单元模式下编写调试系统。过去的六七年里,我也没怎么接触过新的调试代码。因此,我对这个新的尝试感到非常感兴趣,不知道接下来会发生什么,但我确实非常期待。

接下来,我将测试这个方法。我将首先把计数器作为传入的第一个参数,看看能否得到我们想要的效果。如果这样能行得通,效果应该会非常有趣。

我开始尝试修改代码,暂时关闭一些调试代码,想看看在不同的文件和位置中,计数器的值是否会正确显示。最终目标是确保在不同的文件中,计数器能够从零开始递增,逐步验证这一方法的可行性。
在这里插入图片描述

在这里插入图片描述

处理两个翻译单元。我们将为每个翻译单元保留一个调试记录数组

翻译单元在构建批处理文件中定义,具体来说有两个翻译单元:这两个翻译单元定义了代码的不同部分,并且在这些翻译单元的末尾,我们可以清楚地知道它们的结束位置。

如果将计数器放在翻译单元的结束位置,那么它就代表了调试记录的数量。例如,可以定义一个调试记录数组 debug_records,数组的大小与计数器匹配,从而保证能够容纳所有的调试记录。这样,就能确保调试记录存储在一个足够大的数组中。此外,还可以在另一个翻译单元中创建第二个调试记录数组,用于存储与另一个部分相关的调试记录。

通过这种方法,就不需要在多个翻译单元之间复杂地查找或管理记录,而是利用计数器来合理地组织这些调试信息。

我们可以定义 DEBUG_PREFIX 预处理符号来访问适当的数组

如果希望存储并映射调试信息,可以每次覆盖它。比如,在“game debug”中,要使时间块工作,可以通过计数器直接定位查找位置。具体来说,可以定义一个 DEBUG_PREFIX,用来根据编译单元决定调试记录是属于哪个部分。例如,在优化版中,可以使用 DEBUG_PREFIX = optimized,而在主版本中则使用 DEBUG_PREFIX = main,这样就能根据不同的编译单元来确定要访问哪一组调试记录。

为了访问正确的调试记录集,只需根据编译单元设置合适的前缀,这样就能确保获取正确的记录。在实现时,可以通过声明一个 struct debug_record 来定义调试记录,并根据需要提取和访问这些记录。这样的方法可以有效管理不同编译单元中的调试信息。
在这里插入图片描述

定义调试记录数组的字段

通过这种方式,整个问题就被解决了。现在,我们得到了调试记录数组的自动合并,就像是已经完成了一样。接下来,只需要执行写入操作,将数据写入一个已知的位置,这样就不会增加额外的数据量,也不会产生冗余。因此,整个过程变得非常简便且高效。

具体来说,我们只需要关注行号等信息,并确保写入操作的执行。这种方法的优势在于它能够避免不必要的数据扩展,并确保整个过程的高效性,不会带来性能上的负担。

实现 timed_block 构造函数和析构函数

基本思路是,将记录信息填充到相应的调试记录结构中,包括文件名、行号、函数名、时钟值等。这些值会通过宏进行处理,确保正确性。在计算时,应该将 RDTSC(计时器值)进行差值处理,然后将其加到记录中。

对于存储操作,最初我们需要存储一个计数值,但进一步思考后发现,其实不需要直接存储计数器的值,只需要记录指向调试记录的指针。这样处理可以简化代码,并且实现更加高效的操作。

最终的步骤是,对每个操作和记录进行计算和更新,将 rdtsc 添加到记录中,完成时间记录的更新。这种方法的优势是避免了多余的数据存储和复杂操作。对于调试系统来说,这样的做法应该能够正常工作。

为了验证这一点,可以通过打印输出进行检查,看是否能够得到预期的调试记录结果。

在这里插入图片描述

#pragma once
#include "game_platform.h"
#define TIMED_BLOCK \
    timed_block TimedBlock_##__LINE__(__COUNTER__, __FILE__, __LINE__, __FUNCTION__);
struct debug_record {
    const char* FileName;
    int LineNumber;
    const char* FunctionName;
    uint64 Clocks;
};
debug_record DebugRecordArray[];
struct timed_block {
    debug_record* Record;
    timed_block(int Counter, const char* FileName, int LineNumber, const char* FunctionName) {
        Record = DebugRecordArray + Counter;
        Record->FileName = FileName;
        Record->LineNumber = LineNumber;
        Record->FunctionName = FunctionName;
        Record->Clocks -= __rdtsc();
#if 0
        ID = FindIDFromFileNameLineNumber(FileName, LineNumber);
        BEGIN_TIMED_BLOCK_(StartCycleCount);
#endif
    }
    ~timed_block() {  //
        Record->Clocks += __rdtsc();
#if 0
        END_TIMED_BLOCK_(StartCycleCount, ID);
#endif
    }
};

使用新的 TIMED_BLOCK 语法

首先,计划将原本的调试记录逻辑清除,并简化整个过程。通过在合适的位置插入 HitCount(命中计数),可以在每次操作时增加计数,从而实现对事件的统计。不过,值得注意的是,这种方法并不是线程安全的,因此可能需要进一步考虑如何处理多线程环境中的问题,但目前先按这个方向进行。

TIMED_BLOCK 添加 HitCount 作为额外参数

为了处理时间块(TIMED_BLOCK)逻辑,计划通过给 TIMED_BLOCK 传递一个可选的数字参数来动态调整处理的次数。通过这种方式,默认情况下会将计数器加1,但如果希望对某个时间块进行多次计数,则可以通过传递一个不同的计数值来实现。这样可以灵活地处理调试和性能记录,而不需要重复冗余的代码。

在实现过程中,需要注意保持现有功能集的完整性,避免不必要的复杂度。过程中会使用 paste 机制来动态插入参数,确保实现的灵活性和扩展性。

虽然在某些部分出现了小问题,例如扩展不符合预期,但这并不妨碍继续进行改进,最终确保时间块功能按预期工作。

扩展 __LINE__,处理粘贴操作符时的情况

在处理 C++ 宏和预处理器时,遇到了一些困难。问题的核心是需要将某些文本展开成更详细的内容,比如通过宏扩展行号,但 C++ 预处理器的行为非常不直观。由于预处理器将文本作为字符串连接,因此需要通过多层次的宏展开才能正确获取期望的效果。即使通过 paste 操作符将两部分文本连接起来,预处理器依然不能正确地展开数字,因此必须进行多次预处理。

为了使行号扩展正确,需要将宏调用传递多次,通过这种方法来确保数字被正确处理并展开。这种行为并不直观,也没有明确的规则可循,这也是很多 C++ 开发者常常会遇到并感到困扰的问题。

尽管如此,最终问题得到了处理,并且宏的展开成功执行。虽然这种方法看起来并不特别优雅或者高效,但它是 C++ 预处理器的一部分,开发者往往必须习惯这些预处理的细节,尤其是当语言本身缺乏直接支持这些功能的特性时。

此时,虽然完成了这部分工作,但仍然有其他一些需要继续处理的任务,仍需进一步解决。

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

修改 OverlayCycleCounters,使其与调试记录数组兼容

这段内容描述了如何在程序中添加调试记录、打印周期计数器,并调整程序的部分代码以便支持这些功能。以下是详细的总结:

  1. 调试记录数组

    • 通过添加一个名为 DebugRecordArray 的数组,可以存储多个调试记录(debug records)。这些记录用于存储周期计数器数据、文件名、行号、函数名等调试信息。
    • 通过定义数组,程序可以在不同地方记录不同的调试信息。
    • 需要注意,虽然当前只有一个调试记录,但实际上可以有多个调试记录,后续会进一步处理这个问题。
      在这里插入图片描述
  2. 周期计数器输出

    • 通过在程序的结束处添加对调试记录数组的访问,打印周期计数器数据。使用 DebugRecordArray 和数组的元素数量,可以输出记录的内容。
    • 代码还需要处理多个记录,而不仅仅是一个记录。后续将继续完善这一部分。
      在这里插入图片描述
  3. 时间块(Time Blocks)

    • 在代码中添加时间块记录,用于记录游戏更新(game update)和渲染(render)的时间。通过在时间块的结束部分插入相应的记录,能够统计和输出这些过程的执行时间。
    • 时间块用来衡量某些代码块的执行时间,帮助调试和性能分析。
  4. 前向声明和调试记录

    • 由于某些函数调用发生在这些元素的定义之后,程序需要在使用之前进行前向声明(forward declaration)。这保证了可以正确使用和访问调试记录数组。
    • 在代码中使用 debug_record 类型来存储调试记录,并且为每个调试记录指定合适的名称。
      在这里插入图片描述

    在这里插入图片描述

  5. 删除或注释掉不需要的代码

    • 为了清理代码并简化调试过程,删除或注释掉了与周期计数器相关的某些内容(例如 Win32 环境下的输出),可能是为了在后续开发中不再需要这些输出。
    • 这样做是为了防止输出过多的调试信息,保持代码清晰,同时保留可能需要的调试输出功能。
      注释掉
      在这里插入图片描述
  6. 调整和完善功能

    • 目前,所有的时间块和调试记录都已添加,但仍有一些待优化的地方,如多个记录的处理、输出的格式等。
    • 虽然当前的处理方式有效,但后续还可能进行调整,确保记录的准确性和性能分析的有效性。

总结起来,这段代码的修改目标是为了更好地收集和输出性能数据,特别是在游戏更新和渲染等关键部分的执行时间。通过调试记录数组和时间块的插入,能够方便地获取并查看代码执行时的性能信息,同时也清理了不再需要的调试输出。
在这里插入图片描述

在这里插入图片描述

出现段错误
在这里插入图片描述

在这里插入图片描述

13 不怎么对
在这里插入图片描述

__COUNTER__ 是 C 和 C++ 中的一个预定义宏,它用于提供一个在编译过程中每次调用时自动递增的整数值。它从 0 开始,之后每次预处理器遇到 __COUNTER__ 时,都会返回一个递增的值。

工作原理

  • __COUNTER__ 的值在每次遇到时递增,它的初始值为 0,之后每次调用都会递增 1。
  • 该宏是基于预处理器的,和其他宏不同,它不是静态的,而是随编译过程递增的。也就是说,__COUNTER__ 的值和其他宏不一样,它的值在同一个文件中的每次出现都会递增,而不是基于外部输入或者常量。

示例

#include <iostream>

#define MY_COUNTER __COUNTER__

int main() {
    std::cout << "First value: " << MY_COUNTER << std::endl;  // 输出 0
    std::cout << "Second value: " << MY_COUNTER << std::endl; // 输出 1
    std::cout << "Third value: " << MY_COUNTER << std::endl;  // 输出 2
    return 0;
}

在这个例子中,每次使用 MY_COUNTER 时,__COUNTER__ 会自动递增,输出的值依次为 012

使用场景

  • 生成唯一的宏值:可以用来生成唯一的标识符,避免手动管理编号,常用于生成唯一的变量名或者标识符。
  • 日志记录或调试:用于生成每个调试输出的递增编号。
  • 宏中动态生成名称:可以配合 __COUNTER__ 使用,生成不同的宏名、变量名等。

注意事项

  1. 局部性__COUNTER__ 对每个文件和每个编译单元都是独立的。也就是说,如果你在多个源文件中使用 __COUNTER__,它们的计数是互不干扰的。

  2. 递增方式__COUNTER__ 会在每个预处理宏调用时递增,但它并不会重置。只要这个文件进行重新编译,计数会从 0 开始。

  3. 不支持回退__COUNTER__ 不能被手动修改或回退。如果需要从特定的数值开始,或者对计数进行操作,必须手动管理。

通过 __COUNTER__,可以有效避免宏重复定义时的命名冲突,同时也简化了需要递增计数的任务。

之前的测试代码忘记删掉了
在这里插入图片描述

在这里插入图片描述

TIMED_BLOCK 被调用八次
在这里插入图片描述

应该是有的函数没有被调用
在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

回顾一下这个代码

在这里插入图片描述

debug_record DebugRecordArray[__COUNTER__];

DebugRecordArray 是一个宏由编译时候选项决定的
在这里插入图片描述

因为现在是debug
在这里插入图片描述

数组名叫 DebugRecords_Main
数组大小由__COUNTER__ 出现的次数决定的
在这里插入图片描述

TIMED_BLOCK 宏会把函数名和对应的函去构造一个对象
在这里插入图片描述

构造对象会去调用构造函数把 相关的信息保存起来

测试今天的更改

通过上面的步骤,完成了调试记录和计时块的实现,最终在运行时看到预期的输出。这些输出实际上是来自于自己实现的计时记录系统,验证了整个过程的正确性。虽然目前运行结果看起来符合预期,但依然需要进一步的调整和优化。可能还有一些细节需要处理,例如对不同的调试记录进行更细致的管理,或者对多线程环境下的线程安全问题进行考虑。总体来说,这一阶段的工作已经基本完成,并且实现的方案也能够顺利输出预定的数据。

通过调试数组中存储的文件名识别我们的调试计数器

在这一阶段,发现之前使用的名字表已经不再需要,因此可以将其删除。接下来,要通过每个计数器来获取相应的调试记录,而不再依赖于名字表。这个改动意味着不再需要通过名字表查找函数或文件名的信息,可以直接从计数器中获取所需数据。
NameTable[CounterIndex] 替换成 Counter->FileName
整体上,整个调试记录的方式更加简化,不再依赖于之前的查找表,而是直接利用计数器和文件名进行记录,这样能提高性能并减少不必要的开销。
在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

有一种替代方法可以开始/结束计数器,而不使用构造函数/析构函数配对,也不必将数据存储在结构体中。

有一种替代方式可以用来启动和计数器,而不需要使用构造函数和析构函数对,虽然这种方法可能不如直接将数据存储在结构体中方便,但还是值得提一下。这种方式类似于 C# 中的 using 语句,能够自动处理开始和结束的操作,通常在需要处理资源的情况下非常有效。

具体来说,提到了一种方法,通过定义一个 using 宏来实现类似 C# 的 using 语句功能。这种方法的核心思想是,在代码块开始时执行初始化操作,在代码块结束时执行清理操作。这个方式可以避免显式地管理构造和析构的过程,而是依赖于宏来自动处理,从而减少了代码的冗余。

这种方法的实现方式包括一个宏,宏内部处理了进入和退出块时的操作,类似于资源管理的模式。尽管这种方法可能不如直接将数据保存在结构体中那样直观,但它提供了一种便捷的方式来确保某些操作始终在特定的范围内执行,而无需显式地管理生命周期。

C# 中的 using 语句是一个非常有用的语言特性,主要用于确保资源在使用完毕后得到正确释放。它通常用于处理那些需要显式释放的资源,如文件、网络连接、数据库连接等。通过 using 语句,可以保证资源在使用完之后会自动调用其 Dispose 方法来释放资源,无论在执行过程中是否发生异常。

using 语句的基本用法

using (var resource = new SomeResource())
{
    // 使用资源
    resource.DoSomething();
}
// 资源会在这里被自动释放

using 块中,resource 对象会在离开 using 块时自动调用 Dispose() 方法,确保资源得以释放。这个过程是由 C# 编译器自动生成的,它会在 using 语句结束时插入必要的代码来调用 Dispose() 方法。

为什么需要 Dispose() 方法?

某些资源(如文件句柄、数据库连接、网络连接等)属于非托管资源。C# 的垃圾回收器(GC)并不会自动管理这些资源的释放,因此需要开发者显式地释放它们。Dispose() 方法就是用来释放这些资源的。通过实现 IDisposable 接口,类可以提供一个 Dispose() 方法来清理资源。

IDisposable 接口

IDisposable 接口有一个单一的 Dispose 方法:

public interface IDisposable
{
    void Dispose();
}

当一个对象实现了 IDisposable 接口时,可以在 using 块内使用它,这样在 using 块结束时,会自动调用 Dispose() 方法来释放资源。

示例:使用 using 语句管理文件流

假设我们要读取文件内容,通常使用 StreamReader 来处理文件的读取操作。在文件读取完毕后,我们需要关闭文件流。为了确保文件流在不再需要时被关闭,我们可以使用 using 语句。

using (var reader = new StreamReader("example.txt"))
{
    string content = reader.ReadToEnd();
    Console.WriteLine(content);
}
// 在此时,reader 会自动调用 Dispose 方法关闭文件流

在这个例子中,StreamReader 实现了 IDisposable 接口,当 using 块结束时,它的 Dispose 方法会被自动调用,从而确保文件流被正确关闭。

嵌套 using 语句

你还可以在 using 语句内嵌套其他的 using 语句,用于管理多个资源:

using (var resource1 = new Resource1())
using (var resource2 = new Resource2())
{
    // 使用 resource1 和 resource2
}

在上面的代码中,resource1resource2 都会在 using 块结束时自动调用它们的 Dispose 方法。

using 语句和异常

如果 using 块中的代码抛出异常,C# 会确保 Dispose 方法仍然会被调用。这使得即使在发生错误时,资源仍然能够得到正确的释放,避免资源泄漏。

try
{
    using (var resource = new SomeResource())
    {
        // 这里可能会抛出异常
        resource.PerformAction();
    }
}
catch (Exception ex)
{
    Console.WriteLine("An error occurred: " + ex.Message);
}
// 即使发生异常,Dispose 方法仍会被调用

总结

using 语句在 C# 中是一个非常强大的工具,它通过自动调用 Dispose 方法来简化资源管理,确保在使用完资源后能够及时释放它们。它的使用使得代码更加简洁、可靠,特别是在需要显式管理资源的情况下,能够有效避免资源泄漏问题。

写出 elxenoaizd 的建议

这个讨论主要是在考虑使用类似 C# using 语句的结构来简化代码,尤其是在处理资源管理或循环时。想法是通过 using 来包装一个 for 循环,控制其循环条件以及更新步骤。这种方法本质上是通过 using 语句来简化循环的开始、更新和结束步骤。

主要思路

  1. 定义一个 using 语句:例如,使用某个数据(using data),然后在循环中处理它。在每次循环时,都会执行一个条件检查来决定是否继续。

  2. 循环控制:通过 done = AAB 来设置循环结束条件,循环的退出条件是 not done。每次循环结束时会有更新步骤(done = AUB)。

  3. 潜在的问题:引入了不少新变量,可能并没有带来预期的好处。实际上,这样的做法可能增加了代码的复杂性,而没有带来明显的代码简化或性能上的提升。

反思与结论

  • 变量增加:引入新的变量会使代码变得更复杂,反而可能使原本简单的逻辑变得更难理解和维护。

  • 没有显著优势:虽然这种方法看似能够简化循环控制,但实际上它带来的代码增量并没有带来明显的好处。在这种情况下,使用原本的代码结构(可能更直接和简洁)可能更好。

  • 没有明显的改进:对于是否采用这种方式,最终结论是它并不会带来实质性的好处,反而可能增加代码复杂度。因此,这种方法不被看作一个有效的改进。

总的来说,这段内容是在反思是否引入一个复杂的结构来简化代码,最终的结论是这样做可能会带来不必要的复杂度,而没有带来足够的好处。

#define USING(obj, data) for (uint8 done = obj.BeginUsing(data); !done; done = obj.EndUsing(data))

我感觉我错过了一些东西:为什么数组名称在不同的构建之间需要不同?

数组名需要在不同的构建版本之间保持不同,原因是需要打印出两个数组的数据。如果两个数组使用相同的名称,就无法在后续的代码中区分它们,从而无法访问其中一个数组的数据。因此,选择使用不同的数组名是确保可以正确访问和打印两个数组的最简单方式。这是唯一的原因,目的是避免命名冲突并保证可以访问所有数据。

你怎么把光标移动得这么快?这是 IDE 特定的宏吗?

在使用 Emacs 时,通过设置快捷键可以快速移动光标。具体做法是:

  • 按住 Ctrl 键并配合左右方向键(Ctrl + 左右箭头),可以按词跳转,即光标会跳到下一个或上一个单词的开头。
  • 按住 Ctrl 键并配合上下方向键(Ctrl + 上下箭头),可以跳到上下空行的位置。
  • 另外,设置 Alt + j 用于跳转到函数的定义或其他指定的位置。

这些快捷键都很基础,目的在于提高导航效率,减少手动滚动和光标移动的时间。

vscode : ctrl+左右箭头

能否展开讲讲你如何用 uint 来替代 char *Filename / *FunctionName

在讨论 C++ 的时候,提到了对 C++ 的一些批评,特别是关于它的一些复杂性和难度。提出了一个改进的方案:不想直接使用文件名的指针,而是希望编译器能够自动将项目中的所有文件进行枚举,从0到最大文件数。然后,编译器可以创建一个文件名表,这样就可以通过索引来查找文件名。这样,如果需要存储文件名,就可以仅存储一个短整型的文件名索引,这个索引指向该表中的文件名。这样做的目的是简化存储和管理文件名,而不是每次都存储完整的文件路径。总体上,这是在建议一种更高效的方式来处理文件名,而不是依赖于传统的指针方式。

这种方法符合合理的设计思路,尽管在当前的 C++ 环境中,这样的功能可能需要更多的手动实现和调整,无法依赖编译器自动处理。

能否输出结果或在调试器中显示它们?

尽管输出结果或在调试器中查看结果是可行的,但为了实现这一目标,必须使用一个库。之所以没有直接进行输出,是因为目标是尽量避免在游戏开发过程中使用外部库,而是通过手动实现来解决问题。这种做法的目的是避免依赖第三方库,从而更加了解底层实现的细节,确保对代码的完全控制。

game.cpp:打印调试周期计数

在这个过程中,讨论了如何打印出调试信息。首先,考虑使用C标准库中的函数来处理输出,比如stdio.h,但是由于避免使用外部库的原则,考虑到这可能会对代码的控制和结构产生影响。为了调试,使用了周期计数器来追踪并打印出相应的数据,虽然目前这些计数器没有重置,因此它们会不断增加。

为了优化,计划在代码中加入重置功能,以便能够重新计数,这个功能可能会在后续的开发中完善。而且,提到在时间块中插入调试输出时,game updaterender的计时并未按预期执行,因为相应的代码块没有正确结束,这会导致计时不准确。为了解决这个问题,需要确保每个时间块在结束之前都能关闭,从而正确记录并输出调试信息。
在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述
在这里插入图片描述

你推荐将游戏编程作为专业,还是编程本身作为专业?

关于是否选择游戏编程作为专业或一般编程专业的问题,这个问题并没有明确的答案。因为每个人的情况和兴趣不同。在做决定时,重要的是要考虑自己的兴趣所在、职业规划以及行业需求等。如果喜欢游戏开发并且愿意投身其中,那么选择游戏编程作为专业会很有意义。然而,如果更广泛地对编程感兴趣,选择计算机科学或软件工程等更为基础的编程专业也可能是一个更灵活的选择,这样可以为将来的职业发展提供更多的选择。总之,选择专业时要结合自己的兴趣和长远的职业目标来考虑。

还有哪些有用的预处理器值,我们可以使用,除了 __FILE____FUNCTION____LINE__

在调试时,除了常见的 __FILE____FUNCTION____LINE__ 这些预处理器宏之外,常用的其他预处理器宏比较少。__COUNTER__ 是一个比较有用的宏,它可以提供一个每次预处理时递增的计数器,这对调试过程中生成唯一标识符很有帮助。然而,除了这些之外,通常用于调试的预处理器宏并不多,其他的宏一般用于其他类型的功能。

有时候,可能会有一些新的宏被添加到语言标准中,但这些通常与调试关系不大。对于大多数情况,文件、函数和行号这些宏已经足够应对调试需求了。不过,也有可能我忽略了一些新添加的宏,或者它们可能会非常有用。如果有新的宏,我应该了解并尝试使用它们,确保调试过程的更高效。

我不完全理解 Unity 构建。那是将所有内容编译成一个单一文件吗?我不认为是这样,因为我们在 HMH 中确实有多个源文件…

在编译过程中,重要的不是文件的数量,而是编译器如何处理这些文件。虽然在项目中有多个源文件,但编译器看到的是编译单元(compilation unit)。在这个项目中,实际有三个独立的编译单元。编译单元是编译器工作时的基本单位,无论项目包含多少源文件,编译器都会根据编译单元进行处理。

具体来说,三个编译单元分别是:

  1. Win32绑定层:用于重新加载代码和支持实时代码编辑的功能。
  2. 优化代码:为了让编译器能优化某些函数,通常这些函数需要放在单独的编译单元中,尽管有时编译器的处理方式让这变得复杂。
  3. 其他代码:这是项目中其余的部分。

因此,虽然项目有多个源文件,但实际上只会生成三个编译单元。这样就可以通过将源文件包含在一个单独的地方来简化管理,像上面提到的计数器宏的使用也能顺利实现。这种方法使得编译和调试过程更加灵活和高效。

为什么我在许多地方看到那种奇怪的 IInterface CClass 编程风格?

关于接口命名规范和风格的来源,并不完全明确。有一种常见的接口命名方式是以 “I” 开头,比如 “IInterface”。这种风格的具体起源并不清楚,可能是由某个编程语言或库引入的,或者是开发者在编程过程中逐渐形成的惯例。虽然像 “I” 前缀的命名风格在很多地方看到过,但不确定到底是谁最早采用或推广的。

至于 “u3q” 和 “u” 这两个命名的存在,它们可能是某种特定项目或框架中的命名约定,通常这种命名风格可能是为了区分不同的类型或者某种特定的语义。一般来说,使用不同的命名是为了代码的清晰和可读性,避免混淆,但具体原因通常依赖于团队的编码标准或项目的具体需求。

为什么你有 u32uint32 两种 typedef?它们有什么区别?

在这个情况下,u32u32 之间没有实际的区别。最初使用的是 u32,但后来决定切换为 u32,这更多是一个迁移的过程。尽管已经比较老,但还是会时不时地调整编程风格,做一些小的修改。最终,可能会完全删除 u32,并将所有代码统一使用 u32。所以,简单来说,这两个没有本质区别,只是一个逐步过渡的过程。

如果异常不好,那应该用什么方式处理错误?只是返回错误代码吗?像 errnoGetLastError() 这样的全局错误值怎么样?你怎么看待中央错误处理函数的想法?我们调用它并传入错误 ID,它根据不同的错误执行相应操作(也许用 switch 语句)?

处理错误的最佳方法是尽量避免出现错误。许多人在编程时会出现过度错误处理的情况,因为他们把不应该是错误的情况当作错误来处理。因此,首先要做的是尽量避免让不应发生的错误发生。

当确实出现可能的错误时,应当明确地在代码中处理这些错误。比如,假设某个资源文件突然不可读了,这时程序应该能够检测到这一点,并给出明确的提示,如“资产文件丢失,无法继续运行游戏”,这样用户就能知道问题出在哪里。

总的来说,程序中的错误条件应该尽量少。实际上,程序应当设计得能自动处理可能的错误,将其作为程序的一部分处理,而不是让其变成一个“错误”。如果程序无法避免某些错误发生,那么这就不是一个程序错误,而是程序设计的一部分,应当通过适当的处理来回应这些条件,例如使用断言(assertions)来保证程序的状态一致性。如果真的发生了技术性错误,那么就应当有机制来处理,且这些错误应当以一种对用户有意义的方式来处理,而不是单纯地作为“错误”对待。

最终,处理错误的方式和处理其他程序流程没有什么不同:预见问题并设计相应的代码来应对,不让其影响程序的正常运行。

为什么使用 Record->FileName 而不是 Record->FunctionName

使用记录函数名称的做法,最终确实被采用了。实际上,这个问题可能是一个较早的问题。通过记录函数名称,程序能够更方便地跟踪和调试,从而帮助开发者在处理代码时更清楚地了解当前所处的函数或模块。这种做法能为调试和错误追踪提供重要信息,特别是在处理复杂或多个函数的代码时,通过记录函数名称,开发者可以更轻松地定位到具体的执行点或出错位置。这种方法可能最初在讨论中被提到,但后来逐渐被实际使用和验证,证明了它的有效性。

我是新来的,想问一下,你为什么这么讨厌 C++?

对 C++ 产生厌恶的原因主要是因为必须使用它。虽然 C 语言本身被认为是一种不错的语言,尤其是它在 1970 年代末期的设计至今依然具有一定的生命力,但它在现代编程中的局限性显而易见。理想情况下,C 语言应该经过合适的更新,以适应现代编程的需求。然而,现实中得到的却是 C++,这个语言在多个版本的更新中往往加入了许多不合理甚至无用的特性,这使得其变得复杂且难以使用。因此,痛恨 C++ 的原因不仅仅是语言本身,而是因为它是工作中必须使用的工具,并且经常被一些不合适的设计和更新所拖累。

与此不同的是,Python 等其他语言虽然可能存在缺陷,但由于并不需要在工作中使用它们,因此并没有过多的情绪负担。对于不需要接触的语言,不会产生那种想要批评或抱怨的情绪,因为缺乏直接的互动和使用上的痛苦。

这个游戏是用 gcc 编译还是只能用 Visual Studio 编译器?

编译时使用的工具和编译器之间的差异可能会影响某些特性和功能的可用性。GCC 引入了 counter 特性,这是一个在 GCC 中可用的功能,虽然一开始对这个特性有所犹豫,因为不确定早期版本的工具链是否支持它。最初的顾虑来自于无法记起是否所有版本的 GCC 都支持该功能,特别是在老版本的工具链中。后续发现,实际上 GCC 引入了 counter 功能,而其他编译器如 LLVM 和 MCC 在更新版本中也支持了这个功能。因此,虽然早期的版本可能没有该功能,但如今的版本已经能够支持,并且编译的代码可以正常工作。

一般编程问题:你认为在类外访问公共变量是坏习惯吗?还是使用“getter / accessor”函数比较好?

对于公共变量是否可以直接在类外部访问,而不是通过 getter 或者 accessor 函数的问题,我认为这个问题的前提本身就不好。 getter 函数几乎是没有任何实际用途的,我个人认为在任何情况下都不应该编写 getter 函数。我不喜欢这个概念,因为在我看来,私有化(private)也不应该被使用。所以我从不使用类,通常只使用结构体(struct),这样所有的成员变量都始终是公开的,这才是正确的做法。按照这种方法,就不需要 getter 或任何访问器了。

通常,getter 函数的加入只是为了做一些额外的处理,但人们往往会提前为这种访问做一些设计,而这往往是一种非常不好的编程实践。

game.cpp:展示不好的编程习惯

创建一个类 foo,它包含一个整数作为数据,而我们不希望别人直接访问这个数据,只希望他们通过某个方式来操作。于是大家就开始使用 getter 或者 setter,结果每次都得写很多的代码:

比如,想要修改值,原本可以直接操作数据,但是现在必须使用 getter 或 setter,这显得非常繁琐。就为了写这些代码而浪费了大量时间,实际上并没有任何实质的好处。唯一的好处是,如果将来需要添加一些特定的业务逻辑时,可以很方便地进行修改。但如果是为了预先做这些事情而加入访问器,完全是多余的,因为这种情况根本不常见。

而且,加入访问器后,别人写的代码并没有预期到这个改变,所以他们没有为这种方式做测试,也不清楚会不会成为性能瓶颈。例如,原本可能只是一个简单的变量递增操作,结果加上了查找表之类的操作,导致性能下降。

总之,除非确实有必要,不然千万不要加访问器。访问器只有在真正有实际业务需求时才应该出现,并且在实现访问器之前,应该确认它能起到某种有意义的作用。

你会如何处理递归下降解析器中的解析错误/异常?

关于错误或异常的处理,涉及到递归下降解析器时,这是一个较大的问题,需要等到实现游戏解析器时再深入探讨。递归下降解析器是一种常用于解析上下文无关文法的算法,在实际应用中涉及错误处理时,通常需要设计合理的机制来处理解析过程中的异常或错误,避免程序崩溃或产生无效结果。因此,在处理这类问题时,除了要关注解析器的逻辑,还需要考虑如何有效地捕捉并报告错误,确保程序的健壮性和可维护性。

网上说 __COUNTER__ 是 VC++ 中开始使用的

关于 counter 的起源,有人提到它最初可能是在 GCC 上引入的,而另一些人认为它可能最早出现在 MSVC(Microsoft Visual C++)上。讨论中提到,虽然最初在 GCC 中听说过这个功能,但如果它真的先在 MSVC 中出现,那也是很有趣的。无论如何,确认它在 GCC 中的支持是无疑的。如果 MSVC 是最早支持该功能的编译器,那么这也是一个值得关注的点。最终,关于这个话题的问题似乎已经解答完毕。


http://www.kler.cn/a/597340.html

相关文章:

  • 应用权限组列表
  • LeetCode HOT100系列题解之岛屿数量(10/100)
  • 并发编程 面试速记
  • 图像多分类的人工智能
  • 自由学习记录(46)
  • 使用Gitee Go流水线部署个人项目到服务器指南
  • LeetCode hot 100 每日一题(13)——73. 矩阵置零
  • CMS网站模板设计与用户定制化实战评测
  • 风尚云网|前端|前后端分离架构深度剖析:技术革新还是过度设计?
  • Day20-前端Web案例——部门管理
  • JVM垃圾回收笔记01-垃圾回收算法
  • XSS获取Cookie实验
  • DeepSeek vs 通义大模型:谁将主导中国AI的未来战场?
  • TDE透明加密技术:免改造实现华为云ECS中数据库和文件加密存储
  • debian12运行sql server2022(docker):导入.MDF .LDF文件到容器
  • 【3-22 list 详解STL C++ 】
  • 微博ip属地不发微博会不会变
  • Spring Boot定时任务设置与实现
  • 智能井盖:守护城市安全的“智慧卫士”
  • 本地安装deepseek大模型,并使用 python 调用