Bug的分类学(A Taxonomy of Bugs)
关于
The Machinery Roadmap About Us Press Kit
学习 & 支持
API Documentation Books Videos Sample Projects Issue Tracker Academic License
社区
Blog Discord Forum Podcast Books Documentation
2022年4月8日
调试常常是一种被低估的技能。据我所知,学校里并没有真正教授它,相反,你需要在实践中逐渐掌握。今天,我将尝试弥补这一点,看看一些常见的 Bug 以及如何处理它们。
我针对任何 Bug 使用的默认策略是:
- 尝试找到一种可靠地重现 Bug 的方法,以便我可以
- 在 Bug 发生时进入调试器,并且
- 逐行单步执行代码,以便
- 看看它实际在做什么与我认为它应该做什么有什么不同。
一旦你理解了代码的实际行为与你对其行为的心理模型的不同之处,通常很容易识别问题并修复它。
我知道有一些非常成功的程序员实际上并不使用调试器,而是完全依赖于 printf()
和日志记录。 但我真的不明白他们如何做到这一点。 尝试通过一次插入一个 printf()
然后重新运行测试来理解代码正在做什么,似乎比使用调试器效率低得多。 如果你从未真正使用过调试器(我知道,学校不教这些东西),我建议你尝试一下! 习惯于单步执行代码并检查它的作用。
当然,在某些情况下,你无法在调试器中捕获 Bug,而必须求助于其他方法,但我们稍后会介绍,所以让我们开始吧。
拼写错误 (The Typo)
与大多数其他 Bug 不同,Typo 不是由任何有缺陷的推理引起的。 你有正确的想法,只是碰巧输入了其他内容。 幸运的是,大多数拼写错误都会被编译器捕获,但有时你的错误会编译:
if (set_x)
pos.x = new_pos.x;
if (set_y)
pos.x = new_pos.y;
if (set_y)
pos.z = new_pos.z;
一旦你看到它们,修复拼写错误就很简单。 困难的部分是首先看到它们。
拼写错误很难发现,因为就像你阅读带有拼写错误的文本一样,你的大脑会在你阅读代码时自动纠正它。 要擅长校对,你必须强迫你的大脑进入一种不同的模式,在这种模式下,它更多地关注文本本身而不是文本的含义。 这可能很棘手,但你会通过练习变得更好。
如果你无法仅通过阅读代码来发现拼写错误,你可以切换到我们的默认调试方法 —— 逐行单步执行代码并检查每一行是否执行你期望的操作。
你如何防止拼写错误? 似乎你无能为力。 你的大脑偶尔会发生故障,你无能为力阻止它。
我不相信这种宿命论。 相反,我信奉持续小改进的理念。 目标不是完美,目标是每天做得更好一点,随着时间的推移,所有这些小改进的积累将加起来带来巨大的收益。
所以让我们再试一次。 你如何使拼写错误的可能性降低一点?
首先,你应该启用尽可能多的编译器警告,并告诉编译器将警告视为错误。 目标是让编译器检测到尽可能多的拼写错误,以便你可以在它们变成实际 Bug 之前修复它们。
对我来说,一个有很大不同的警告是 -Wshadow
。 -Wshadow
使在子作用域中重用变量名成为错误。 这可以防止愚蠢的错误,例如:
int test = x;
{
int test = f();
g(test); // <-- 意味着使用来自外部作用域的“test”。
}
在我启用 -Wshadow
之前,我犯了很多这样的错误。 大多使用非常通用的变量名,例如 i
或 x
。
其次,使用源代码格式化程序并通过它运行所有源代码。 我们使用 clang-format
并在 Save 和 git commit
上自动运行它。 源代码格式化程序有时会显示拼写错误。 例如,如果你输入这个:
if (x > max);
max = x;
源代码格式化程序会将其更改为:
if (x > max)
;
max = x;
这使得 Bug 更加明显。
你可以做的另一件事是以产生较少拼写错误的方式编写事物。 例如,我过去常常这样编写 for 循环:
for (uint32_t i=0; i<tm_carray_size(items); ++i) {
child_t *children = get_children(items[i]);
for (uint32_t j=0; j<tm_carray_size(children); ++j)
...
}
但是,我注意到这通常会导致拼写错误,我会写 children[i]
而不是 children[j]
。 所以我开始改为:
for (uint32_t item_i=0; item_i<tm_carray_size(items); ++item_i) {
child_t *children = get_children(items[item_i]);
for (uint32_t child_i=0; child_i<tm_carray_size(children); ++child_i)
...
}
有了这个,我就不太可能写 children[item_i]
。 这些天,我已经切换到只迭代指针:
for (const item_t *i = items, *ie = tm_carray_end(items); i != ie; ++i) {
child_t *children = get_children(*i);
for (const child_t *c = children, *ce = tm_carray_end(children); c != ce; ++c)
...
}
由于 i
和 c
现在具有不同的类型,因此不可能混淆它们。 而且如果我不小心写了 *ce = tm_carray_end(items)
,这也会给出编译错误。
每个人都会犯不同的拼写错误,因此找到适合你通常犯的拼写错误的防御策略。 一个通用的技巧是为不更改的变量使用 const:
const uint32_t n = tm_carray_size(items);
这可以防止你以后意外更改变量。
最后,对我来说,一个非常常见的拼写错误来源是当我复制粘贴一些代码但没有正确修补它时。 上面的第一个代码片段就是一个例子:
if (set_x)
pos.x = new_pos.x;
if (set_y)
pos.x = new_pos.y;
if (set_y)
pos.z = new_pos.z;
我复制粘贴了前两行,然后忘记将其中一个 x
更改为 y
。 但我不想停止复制粘贴,它节省了很多时间。 我也不确定手动输入重复代码是否真的会降低错误率。
我发现有两件事对此有帮助。 第一个是多重选择功能,它在许多现代代码编辑器中都可用,例如 VS Code。 使用它,我首先粘贴代码,然后通过选择第一个 x
并重复按 Ctrl-D
直到它们都被选中,然后最终通过按一个键将它们全部更改为 y
来多重选择所有三个 x
。
第二个是 Copilot,来自 GitHub 的 AI 辅助自动完成技术。 Copilot 非常擅长识别像这样的重复编程模式,我发现让 Copilot 自动填充代码比复制粘贴然后手动整理它更不容易出错。 我还不愿意让 AI 驾驶我的汽车,但我愿意让它为我编写重复的代码。 如果你还没有试用过 Copilot,我建议你试一试。
逻辑错误 (The Logical Error)
当你想到 Bug 时,逻辑错误 可能是你主要想到的事情。 当你编写的代码实际上没有执行你想要它执行的操作时,就会发生逻辑错误。
一个常见的例子是 差一 错误,你做的事情比你应做的多一件或少一件。 例如,这段从数组中删除项目的代码:
memmove(arr + i, arr + i + 1, (num_items - i) * sizeof(*arr));
--num_items;
关于逻辑错误的好处是,一旦你有一个重现的案例,它往往是 100% 可重现的,因为代码每次的行为都相同。 所以你通常可以通过单步执行代码来弄清楚发生了什么。
为了降低逻辑错误的风险,你可以做的第一件事是简化你的表达式。 代码越简单易懂,你对逻辑感到困惑的机会就越小。
另一件有帮助的事情是减少代码中可能的路径数量。 即,而不是像这样:
// 快速路径,用于删除数组中的最后一项。
if (i == num_items - 1)
--num_items;
else {
--num_items;
memmove(arr + i, arr + i + 1, (num_items - i) * sizeof(*arr));
}
每次都调用 memmove()
,并指望零字节的 memmove()
仍然会非常快。
为什么? 好吧,首先,拥有更少的代码意味着更小的 Bug 风险。 但更重要的是,如果你有只偶尔执行的代码路径,它们将不会像代码的其余部分那样得到那么多的测试。 Bug 可能会隐藏在那里,并偷偷通过你的快速测试,最终在生产环境中爆炸。
总的来说,争取线性代码 —— 代码以逻辑方式从一行到下一行进行,你可以将其作为连贯的故事阅读,而不是必须在代码中跳来跳去才能理解发生了什么。
另一件有帮助的事情是使用标准习惯用法。 例如,如果你需要经常擦除项目,你可以为其引入一个宏:
#define array_erase_item(a, i, n) \
(memmove((a) + (i), (a) + (i) + 1, ((n) - (i) - 1) * sizeof(*(a)), --(n))
现在如果存在逻辑错误,错误将位于一个地方,并且可以更轻松地修复。
意外的初始条件 (The Unexpected Initial Condition)
另一种可能性是你的逻辑是完美的,但你的代码仍然失败,因为数据的初始状态是你没有预料到的。 即,如果数据处于你期望的状态,一切都会很好,但由于它不是,该算法失败了:
flag_t flags[MAX_FLAGS];
uint32_t num_flags;
voidadd_flag(flag_t flag) {
flags[num_flags++] = flag;
}
上面的代码在假设 num_flags < MAX_FLAGS
的情况下运行良好,否则它将写入数组末尾之外。
这是否意味着应该重写代码以使用动态分配的内存来消除 MAX_FLAGS
限制? 不,不一定。 在你支持的内容中设置限制是完全可以的,事实上,所有代码都这样做。 如果你切换到动态分配的数组,如果你的标志超过 UINT32_MAX
,代码仍然会失败。 而且如果你将 num_flags
更改为 uint64_t
或某种 “bignum”,你最终仍然会在某个时候耗尽内存。
如果你永远不希望有超过几个标志,那么将 MAX_FLAGS
设置为 32
或类似的值是完全可以的。
处理意外初始条件的最佳方法是明确你的期望。 有些语言在语言中内置了工具,以你可以为函数指定的 前提条件 的形式。 在 C 中,最好的方法是通过断言:
flag_t flags[MAX_FLAGS];
uint32_t num_flags;
voidadd_flag(flag_t flag) {
assert(num_flags < MAX_FLAGS);
flags[num_flags++] = flag;
}
有时可能不清楚谁应该为不良的初始条件负责。 是函数未能处理该特殊情况的错,还是调用者向函数发送错误数据的错? 清楚地记录可接受的初始条件并添加断言来检测它们会将责任推给调用者。
内存泄漏 (The Memory Leak)
当你的代码分配了永远不会释放的内存时,就会发生内存泄漏。 内存泄漏不是你唯一需要担心的泄漏,代码还会泄漏其他东西,例如 线程、临界区 或 文件句柄。 但内存泄漏是迄今为止最常见的泄漏,所以让我们专注于此。 在 C 和 C++ 中,分配内存的标准方法是调用 malloc()
或 new
以从系统分配器获取一些内存。 许多其他语言都有类似的方法,其中创建对象将从全局分配器分配一些内存。
在这种设置中查找和修复内存泄漏非常困难。 首先,你通常甚至不知道存在内存泄漏,直到你完全耗尽系统内存,或者在 任务管理器 中注意到你使用的内存比你预期的多几个 GB。 较小的内存泄漏可能永远不会被检测到或修复。
其次,要修复它,你需要找出谁分配了他们从未释放的内存。 这真的很难,因为在代码中,你只有一堆散布的 malloc()
和 free()
调用 —— 你怎么知道缺少 free()
的地方?
在具有 自动内存管理 的语言中 —— 垃圾回收或引用计数 —— 这不是一个问题,因为在这些语言中,当不再有对它的引用时,内存会自动释放。 但是,这并没有完全消除泄漏。 相反,它会将 内存泄漏 换成 引用泄漏,其中某人持有对他们应该放弃的东西的引用。 此引用使对象保持活动状态,有时是一整棵对象树,浪费内存。
引用泄漏可能比内存泄漏更难处理。 手动内存管理迫使你明确谁 拥有 一块内存,因此如果该块内存没有被释放,你将知道谁是罪魁祸首。 通过自动内存管理,没有单独的指定所有者,任何人都可以持有使内存保持活动的引用。
处理内存(和其他资源)泄漏的最佳方法是向内存分配添加检测。 即,而不是直接调用 malloc()
,你调用一个包装函数,让你传入一些额外的参数。 例如:
item_t *p = my_malloc(sizeof(item_t), __FILE__, __LINE__);
my_malloc()
可以使用这些额外的信息来记录所有内存分配:大小、指针、文件名以及分配发生的行号。 相应的 my_free()
函数可以记录所有 free()
调用。 然后,我们可以将所有调用转储到日志中(或以某种其他方式分析它们)以查找内存泄漏。 如果有人在分配内存而不释放它,我们可以查明发生该情况的文件名和行号,这通常足以找出 Bug。 记录所有内存调用会带来一些开销,因此你可能希望将其保存到特殊的 “Instrumental” 版本中,尤其是在你有大量小型内存分配的情况下。 (你通常应该尽量避免这种情况。)
在 The Machinery 中,我们更进一步。 不是让所有东西都通过的单个全局分配器,引擎中的每个系统都有自己的分配器。 在许多情况下,系统分配器只是将分配调用转发到全局分配器,但是拥有特定于系统的分配器的优势在于,我们可以跟踪该系统中分配的总内存量,而无需太多开销(我们只需将分配的大小添加到计数器中并在释放时减去)。 这使我们可以轻松地查看每个系统中使用的内存量。 此外,当系统关闭时,我们确保内存计数器为零。 如果不是,我们会报告该系统中的内存泄漏,并且用户可以使用该系统的检测版本进行更详细的分析。
使用这种方法,内存泄漏的问题几乎完全消失了。 我们仍然会偶尔创建内存泄漏,因为我们是人并且会发生错误,但是它们会被快速检测到和修复。
内存覆盖 (The Memory Overwrite)
当一段代码写入它不拥有的内存位置时,就会发生内存覆盖。 通常有两种情况会发生这种情况。
- “释放后写入” 是指系统在释放指针后写入指针。
- “缓冲区溢出” 是指系统写入超出其已分配数组的末尾。
内存覆盖 Bug 的最大问题是它们通常不会立即显现出来。 相反,它们可能会稍后在代码的完全不同的部分中爆炸。 在 释放后写入 的情况下,系统分配器可能已经回收了释放的内存并将其分配给其他人。 然后,写入操作将破坏该系统的数据,这可能会导致崩溃或当该系统尝试使用它时出现奇怪的行为。
在 缓冲区溢出 的情况下,最常见的是代码会写入已分配内存之后的字节。 通常,这些字节被内存分配器用于存储各种簿记数据,例如,将内存块链接在一起。 写入将破坏该数据,这通常会导致内存分配器内部在稍后尝试使用数据时发生崩溃。
由于崩溃发生在与 Bug 源自的代码完全不同的部分中,因此很难查明问题。
内存覆盖 Bug 的明显迹象是你在代码的不同部分中发生大量奇怪的崩溃,通常在内存分配器本身中,并且当你查看数据时,它看起来已被破坏。 一些托管语言通过完全阻止代码访问它不 “拥有” 的内存来使内存覆盖成为不可能。 但请注意,这也常常限制了该语言可以做什么。 例如,在这种语言中编写自定义内存分配器可能很困难或不可能。
有时你可以通过崩溃发生的模式来猜测问题可能在哪里。 你可以尝试的另一件事是关闭应用程序的大部分来尝试查明它。 例如,如果禁用声音时 Bug 消失,你可以怀疑问题出在声音系统中。 如果 Bug 最近出现,你还可以尝试 git bisect
来查找引入它的提交。
但这些都是非常钝的工具。 如果我们可以在 它发生时 捕获 Bug,而不是让事情稍后爆炸,那就更好了。
在 The Machinery 中,我们有一种方法可以做到这一点。 还记得我说过我们到处都使用自定义分配器吗。 为了捕获内存覆盖 Bug,我们将我们的标准分配器切换为 页尾 分配器。 此分配器不使用 malloc()
,而是直接从 VM 分配整个内存页,并且它将用户请求的内存放置在内存页的最末尾(因此得名 页尾 分配器):
将分配与页面末尾对齐。
在代码中,它看起来像这样:
const uint64_t size_up = (size + page_size - 1) / page_size * page_size;
char *base = tm_os_api->virtual_memory->map(size_up);
const uint64_t offset = size_up - size;
return base + offset;
类似地,当我们释放内存时,我们会释放整个页面。
由于 free()
现在完全取消映射 VM 中的内存,因此释放后写入将不再破坏其他可怜系统的数据。 相反,它将立即导致 访问冲突。 这意味着你将不再需要猜测尝试找出错误写入来自哪里,你将在代码中发生该错误的确切位置获得访问冲突。 从那里,找出 Bug 通常很简单。
类似地,由于我们将分配放置在页面的最末尾,因此缓冲区溢出会进入下一页,该页面尚未映射,并再次触发访问冲突。
自从我们开始使用此策略以来,我们实际上没有任何与内存覆盖 Bug 相关的大问题。 它们仍然不时发生,但是当我们注意到明显的迹象时,我们通常可以通过启用页尾分配器来快速找到它们并消除它们。
竞争条件 (The Race Condition)
我将使用 竞争条件 作为任何一种多线程 Bug 的常用名称。 当不同的线程接触相同的数据并且它们的更改以意想不到的方式交互时,就会发生竞争条件。
竞争条件可能很棘手,因为它们与时间有关。 即,Bug 可能仅在两个线程恰好在完全相同的时间接触完全相同的事物时发生。 这可能意味着 Bug 出现在一台机器上,而不是出现在另一台机器上。 这也可能意味着,如果你添加一些打印语句来弄清楚发生了什么,则时序会发生变化,并且 Bug 会消失。 这可能非常令人沮丧。
它们也可能很棘手,因为多线程代码很难推理。 尤其是在当今时代,你编写的代码可以被编译器或 CPU 重新排序。
那么你能对线程 Bug 做些什么?
好吧,你可以使用一种消除竞争条件可能性的语言。 是的,Rust 的朋友们,这是你一直在等待的时刻! 现在是你发光的时候了!
Rust 通过跟踪谁有权写入每条数据,并确保没有两个线程同时具有对同一条数据的写入权限,从而巧妙地消除了大多数(并非全部)线程问题。
Rust 爱好者认为,由于未来将越来越趋向于多线程,并且由于没有这些检查的多线程代码太难编写,因此 Rust 是系统编程的未来。 我并不信服。 我非常重视简单性,而 Rust 似乎是一种非常复杂的语言,但我们会看到的。
除了 Rust,你还能对这些 Bug 做些什么?
好吧,首先,有必要确保你实际在查看线程 Bug,而不是其他东西。 我喜欢在每个系统中设置一个标志,强制它以单线程方式运行。 这样,可以相当快速地检查禁用多线程是否可以解决问题。 如果是这样,你可以怀疑线程 Bug 并开始深入挖掘。
下一步可能是在代码的可疑部分插入一些额外的临界区,以强制它一次运行一个线程。 如果这可以解决问题,你可以怀疑该代码部分中的多线程逻辑存在问题。
但是,竞争条件总是难以修复。 最好是你可以防止它们首先发生。
做到这一点的好方法是简化你的线程代码。 我发现多线程代码真的很难推理。 使用单线程代码,你只需在脑海中逐行单步执行即可。 使用多线程代码,你必须考虑线程可能执行指令的每种可能的顺序,包括编译器或 CPU 可能的重新排序。 对于一个小脑袋来说,这需要处理大量的排列组合。
所以不要试图对多线程代码感到满意。 除非你真的、真的、真的、真的、真的确定你需要它,否则不要尝试实现巧妙的无锁算法。 坚持使用一些众所周知的模式并在整个代码中使用它们。
Go 就是一个很好的例子。 Go 没有与 Rust 相同的多线程安全功能。 但是 Go 鼓励使用 goroutine 和通道的多线程模型很容易理解,并且即使它不能完全消除出错的可能性,也会推动用户使用安全的多线程编程模式。
在你的竞争条件武器库中拥有的另一个有用工具是 Clang 的 线程清理器。 线程清理器可以在许多可能的竞争条件发生之前提醒你。
设计缺陷 (The Design Flaw)
有时问题不是特定代码中的 Bug,而是无论你如何编写代码,代码都不可能工作,因为其背后的整个想法都有缺陷。 这可能听起来很奇怪,所以让我们看一个简单的例子:
// 如果“s”未进行 HTML 编码,则将 HTML 编码(< 等)添加到“s”并
// 返回它,如果“s”已进行 HTML 编码,则保持不变地返回它。
constchar*ensure_html_encoded(constchar*s);
乍一看,这似乎是合理的,但是此函数背后的想法是有缺陷的。 问题是没有办法判断字符串是否经过 HTML 编码。 字符串 <
可能是字符串 <
的 HTML 编码版本,或者可能是用户实际上想要获取字符串 <
并对该字符串进行 HTML 编码!
此设计缺陷可能会在程序中潜伏很长时间,直到有一天有人尝试使用 ensure_html_encoded()
来编码看起来像已经过 HTML 编码的字符串,然后整个事情就会爆炸。
无法通过更改 ensure_html_encoded()
的实现来修复此问题。 修复它的唯一方法是更改设计本身,并将 ensure_html_encoded()
替换为 html_encode()
之类的东西,无论它看起来是否已经过 HTML 编码,该函数始终对输入字符串进行 HTML 编码。
但是你不能简单地将对 ensure_html_encoded()
的所有调用替换为对 html_encode()
的调用,因为传递给 ensure_html_encoded()
的某些字符串可能已经过编码,如果你对它们调用 html_encode()
,它们将被双重编码。 相反,你必须彻底修改 HTML 编码的整个逻辑,并确保你正确跟踪哪些经过 HTML 编码,哪些没有。
对于初级程序员来说,设计缺陷 Bug 可能很棘手,因为它们要求你退一步并查看更大的图景,并意识到问题不是你试图修复的特定 Bug,而是整个想法都有缺陷。
上面的示例非常简单,设计缺陷可能比这更复杂且更难发现。 一个非常常见的例子是你有一个函数 f()
从两个(或更多)位置 g()
和 h()
调用,其中 g()
和 h()
希望 f()
以稍微不同的方式执行其操作(文档没有准确说明 f()
的作用)。 g()
的作者提交了关于 f()
行为的 Bug 报告,但是修复该 Bug 会破坏 h()
,然后 h()
提交了一个新的 Bug 报告,只有通过破坏 g()
等来修复该报告。 最后,唯一的解决方案是将 f()
拆分为两个单独的函数 f_a()
和 f_b()
,它们执行相似但略有不同的操作。
没有容易的方法可以找到设计缺陷,你能做的最好的就是退一步,仔细考虑可能存在的关于一段代码做什么的未说明的假设,并确保将这些未说明的假设更改为已说明的假设。
类似地,没有容易的方法可以修复设计缺陷。 根据问题的大小以及代码被调用的频率,可能需要进行大量的重构。
第三方 Bug (The Third-Party Bug)
第三方 Bug 是不在你的代码中,而在你恰好正在使用的其他人的代码中的 Bug。 你可能会认为第三方 Bug 不应该是你的问题,因为它不是你的错! 但是猜猜怎么着,如果 Bug 阻止你的软件工作,那就是你的问题。 你真倒霉!
第三方 Bug 分为各种不同的类别:
- 你使用的第三方库中可能存在真正的 Bug。
- 你可能以错误的方式使用该库,所以实际上,确实是你的错。
- 第三方库的文档可能不是很清楚它在某些情况下的行为方式,因此不清楚是否存在 Bug。 第三方库的制作者甚至可能不知道。
在修复第三方 Bug 时,你可能会发现自己处于以下三种情况之一:
- 第三方库的创建者会快速响应 Bug 报告并帮助你解决问题。
- 创建者不是很积极,但是你有该库的源代码,因此你可以尝试诊断正在发生的事情并自己修复它。
- 创建者没有响应,你也没有源代码,你基本上是在处理一个黑匣子。
在 The Machinery 方面,我们尝试处于第 1 类。 此外,如果你拥有 Pro 许可证,你还将拥有源代码。
如果你处于第二种情况,即你有源代码但几乎没有支持,那么你面临着理解其他人的源代码如何工作的任务。 这可能从相对容易到非常困难不等,具体取决于该代码的状态和质量。 它也是一种特殊的技能,你可以随着时间的推移而变得更好。
第三种情况可能非常令人沮丧。 如果你面临着试图调试黑匣子,那么你唯一能做的就是尝试以各种方式戳它以查看会发生什么。 如果你幸运的话,你也许能够对黑匣子建立足够准确的心理模型来修复 Bug。 也许某些标志的工作方式与文档所说的不同。 也许该函数在某些类型的输入上崩溃。 也许你可以编写一个小循环,使用各种不同的值调用黑匣子,以找出触发问题的原因。 祝你好运!
失败的规范 (The Failed Specification)
猜猜怎么着,有时你是第三方。 哦,世界变化真快!
当然,当你你编写了其他人正在调用的函数时,你的视角会完全改变。 你看到的不是一个神秘莫测的黑匣子,它以完全难以理解的方式对完全合理的输入做出反应,而是看到一群无知的用户以错误的顺序使用一系列毫无意义的参数调用你的函数。
但是,如果你想成为一名成功的库编写者,你不应该将此视为 用户错误。 相反,你应该将其视为 沟通失败。 你未能向用户传达如何正确使用你的 API。
你能对此做些什么? 你可以提供更好的文档或工作代码示例来展示如何使用 API。
但更好的是设计 API 以防止滥用,或者至少确保滥用会导致可理解的错误消息。
你如何设计来防止滥用? 确保每个函数都有一个清晰、单一且易于理解的目的。 不要让函数根据传入的参数/标志完全更改行为。 避免需要以特定顺序调用函数或需要系统处于特定 “状态” 的设计。 当然,这些事情并非总是可以避免,但请尽可能减少它们。 利用类型系统来防止以 “错误” 的方式使用你的 API。
让我们看一个例子:
// 开始一个分析范围。
voidprofiler_begin_scope(constchar*name);
// 结束使用 [[profiler_begin_scope()]] 开始的分析范围。
voidprofiler_end_scope();
看起来不错,但是存在滥用的可能性。 如果有人在没有首先开始范围的情况下调用 profiler_end_scope()
怎么办。
// 开始一个分析范围。
profiler_scope_t profiler_begin_scope(const char *name);
// 结束使用 [[profiler_begin_scope()]] 开始的分析范围。
voidprofiler_end_scope(profiler_scope_t scope);
在这里,我们要求用户传入范围的标识符。 请注意,从分析器的角度来看,这并非绝对必要。 分析器可以在内部堆栈上跟踪任何与范围相关的数据。 但是通过要求 scope
参数,用户不能只调用 profiler_end_scope()
而不首先调用 profiler_begin_scope()
。 而且如果用户调用:
profiler_scope_t p = profiler_begin_scope("update");
如果没有匹配的 profiler_end_scope()
,编译器会给出关于未使用的变量 p
的警告。
此外,在分析器内部,我们可以添加运行时检查(断言),如果传递给 profiler_begin_scope()
和 profiler_end_scope()
的标识符不匹配,则会触发这些检查。
最后,始终让用户获得源代码,以便他们可以自己调试问题。 即使你正在构建专有的商业软件,也要考虑使用某种方式与你的高级用户共享源代码。 这将使你的支持更容易,而且通常风险较低(即使存在大型公共源代码泄漏,似乎也不会对公司产生不利影响)。
难以重现的 Bug (The Hard-To-Reproduce Bug)
标准调试技术取决于我们能够重现 Bug,以便我们可以在调试器中查看它。 如果我们无法可靠地重现 Bug,这将立即失败。