有符号整数有害论¶
标题:Signed Integers Considered Harmful
日期:2022/11/22
作者:Robert C. Seacord
链接:https://www.youtube.com/watch?v=Fa8qcOd18Hc
注意:此为 AI 翻译生成 的中文转录稿,详细说明请参阅仓库中的 README 文件。
备注一:看得我想睡了,总结就是无符号 UB 更少。
备注二:发现我早期烤制的另一份相关演讲。
开场白与背景¶
我的名字是 Robert Secord。如果你们中有人昨天参加了奥斯陆 C++ 用户组的活动,你会发现这场演讲与昨天有很多重叠。我今天将仅仅聚焦于昨天演讲的下半部分,也就是有符号(signed)与无符号(unsigned)整数的对比,并提供更多的细节。我今天没有准备很多幻灯片——我通常都会准备很多——这意味着演讲时间应该刚好够,而且在此过程中我也可以随时回答问题。我的行程安排得很紧,外面已经有一辆车在等我,要马上送我去火车站,然后我要赶去机场飞回葡萄牙。等我在这里的工作结束,我的假期还有三天。
所以,演讲结束后我就得马上跑路,大家有什么问题请在演讲期间提出来。如果你在演讲结束后挡在我通往大门的路上,要知道我体重将近 300 磅,撞上去大概会很疼的。那么让我们开始吧。
现场小测验:为什么选择无符号整数?¶
我昨晚做过这个小测验,但我今天要再做一次,以防今天的观众群体有所不同。这个测验我已经做过两次了,每次得到的结果都非常相似。问题是关于:你什么时候、为什么会选择使用无符号整数?
第一个答案是:只有当你需要取模(modulo)行为时,你才会使用无符号整数。这也是你选择无符号整数的唯一原因。
第二个答案是:你使用无符号整数,是因为你想表示一个不可能变为负数的值。它只能是零或正数。
最后第三个答案是:我是来蹭零食的。他们不给我看结果,在这场争论中我没有“参赛的马”(horse in this race)。“horse in this race”这个习语在这里翻译得通吗?是的,我的意思是,我不太关心结果是什么,我对这种事情没有意见。
好的。那么,有多少人选第一项——你仅仅为了取模行为才使用无符号整数?一、二,两个人。好的。有多少人选第二项——你仅仅为了表示不可能为负的值而使用无符号整数?好的。有多少人只是为了零食而来的?好的。所以,这是一个非常一致的结果。甚至连选零食的人数每次都差不多。我觉得,是的,我的问题出在“仅仅(only)”这个词上。我应该说“两者皆是”。哦,也许“仅仅”在这里不是个好词。是的,这更多是关于你什么时候做出这种选择。我会改进一下措辞的,但我对这个结果很满意。支持中间那个答案的比例通常是 20 比 1 或 30 比 1。
好的。昨晚我讲了“整数解析(Integers Explained)”的内容,但在这里我把它砍掉了。所以,我基本上省略了介绍性的材料。这很明显,对于昨晚在场的人来说,那大概不是什么你们不知道的东西。它只是个很好的热身,能让人进入后续内容的思维状态。所以如果你一开始就有问题,没关系,尽管问。
我接下来会陈述那些支持使用有符号整数的论点。然后我再陈述支持使用无符号整数的论点。我会尝试——并且肯定会失败——去表现得毫不偏袒,因为我真的有很深的偏见,而且无论我怎么做,这种偏见都会开始显露出来。
支持有符号整数的论点及其缺陷¶
当你支持第一个观点,即你仅仅为了取模运算才使用无符号整数时,你其实就是在主张“几乎在所有情况下都应该使用有符号整数”。而支持这个观点的第一个,或许也是最强有力的论点是:那些由下界限制的循环该怎么办?
我们很容易写出永远为真或永远为假的测试条件。因为 size_t 是一个无符号类型,它被用来表示系统中能够分配的最大对象的大小。在 32 位架构上,它通常是 32 位的无符号类型;在 64 位架构上,它通常是 64 位的无符号类型。所以 i 是无符号的,意味着它永远不可能取负值。我们来看下面这个循环,只要 i 大于或等于零,它就会一直循环,每次递减 i:
for (size_t i = size; i >= 0; --i) {
// 循环体
}
显而易见,这是一个无限循环。如果我退得太远,麦克风就开始产生回音了。所以,这大概是个错误。但从代码表面来看,并没有明显的缺陷。从语言的角度来看,这段代码没问题。这里没有未定义行为(UB)之类的东西。所以,这可能不太会被诊断出错误。至于它到底是不是一个错误,取决于算法以及它想要实现什么功能。
但通常情况下,当你计数时,发生环绕(wraparound)就是一个错误对吧?打个比方,如果你是贝佐斯,你的银行账户里有 40 亿美元,你再往账户里存 1 美元,然后你的账户变成了 0 美元——你通常会认为这是一个错误。
所以,按照有符号整数倡导者的说法,问题在于这个循环可以通过使用有符号整数来改进,对吧?这里,我们将使用一个有符号的 size_t 类型(ssize_t)。需要注意的是,这个类型是由 POSIX 定义的,它并不在 C 语言标准中。我们曾经有一个技术报告(TR)里包含了一个有符号的 size_t 类型。如果当时这个东西被提请投票以进入标准,我绝对会竭尽全力去阻止它——除非这部分被修改掉。
你看,我在这里和那里都做了注释。看吧,我的偏见已经开始显露出来了。但是,这是我誓死都要坚守的阵地(this is a hill I’m gonna die on)。我会站在这个山头上,只要我还有一口气在,我就要把有符号的 size 类型挡在 C 标准之外。
另外关于 C++,他们有一个模板类,可以接受任何类型的无符号类型并将其转换为有符号类型,也可以接受任何类型的有符号类型并将其转换为无符号类型。所以,仅仅因为你有一个 size_t 类型,就意味着你可以创造出该类型的有符号等价物。显然,这个概念在 C++ 中是存在的。同时,它也被 POSIX 定义了。
所以现在,我们有了一个可以终止的循环。这个有符号的 size 类型是做什么的呢?POSIX 将其定义为取值范围从 -1 到 SSIZE_MAX。所以他们其实并不关心所有的负值范围。他们只是试图定义一个“不是有效计数值”的值,对吧?这样他们就可以有一个用来表示错误的值(通常如此)。所以这是一种内联错误(inline errors)的处理方式,而这种设计本身就是有缺陷的。你应该把你的值和你的错误状态分离开来。C++ 在这方面做得很好,你有异常机制来报告错误,你不需要把错误包裹在数值里。
所以,这个循环现在可以终止了。根据有符号整数的论点,原因是:有符号整数在零附近有非常良好的、正常的行为,而零是一个非常常见的值,对吧?开发人员经常会有一些整数值处于零以上某个增量或零以下某个增量的位置。所以有符号整数很好,在零附近的数学运算都能完美运行。
然而,这段代码确实包含了一个从无符号类型到有符号类型的转换。显然,并非所有能用 size_t 表示的值都能用这个有符号的 ssize_t 类型表示。所以这段代码存在问题。你可能必须添加一些检查,以确保该值能够被有符号类型表示。如果不能,你必须将其视为某种错误情况。正如我们在这个例子中看到的,转换为有符号类型也是有点棘手的。
当任意整数类型被转换为有符号整数类型时,如果该值可以被表示,它就会被保留;但如果不能被表示,结果要么是实现定义的(implementation-defined result),要么会引发一个实现定义的信号(implementation-defined signal)。所以这个转换过程可能会出各种岔子,它并非是一个毫无问题的操作。
如果一个无法被表示的 size 值被频繁地转换为一个负的有符号值,对于这个特定的循环来说,将导致循环立即终止。那这就是应该发生的事情吗?我不知道。我不知道一个“负的长度”到底代表什么鬼东西,对吧?因为这根本不是一个现实存在的概念。你拥有零个某物,然后你得到一个,现在你拥有一个某物。你要么有正数个某物,要么有零个某物。即便是这个有符号的 size 类型也承认这一点,他们仅仅允许 -1 作为没有有效计数的指示。
所以,如果 size 无法被表示,从而被转换成了一个负值,那个循环就会终止。
类型转换问题与循环的正确写法¶
另一种解决方案是将 i 初始化为 size - 1,并在每次迭代时递减:
for (size_t i = size - 1; i < size; --i) {
// 循环体
}
现在,当计数器到达零时,递减操作会导致计数器环绕到可能的最大值,即 SIZE_MAX。当然,这是完全明确定义的行为(well-defined behavior)。无符号整数就是这样通过环绕来工作的。现在 i 的值大于了 size。因此,循环的终止条件计算结果为假,循环随之终止。重申一下,这在 C 和 C++ 语言中都是明确定义的。
无符号整数的环绕问题与安全性¶
但是,无符号整数的环绕是有问题的。它在多个抽象层面上都存在问题。
在最低的抽象层面上,它的问题在于,假设你同意了刚才第二个观点(也就是我们举手赞同的那个):大小(sizes)应该用无符号类型来表示。如果你采纳这个观点,那么你用来计算大小以及计算指针位置的所有算术运算,都是无符号算术运算。这意味着你永远不会遇到溢出(overflow)——无符号整数不会溢出,它们只会发生环绕(wraparound)。
所以,你只需要处理环绕问题。而环绕就成了一个问题,这种类型的问题真的与安全漏洞息息相关。如果你在内存安全方面出了问题——比如你算错了对象的长度,分配了太少的存储空间,上限设置错误,或者指针算术错误——你很容易就会写到对象边界之外去,因为你已经失去了对边界位置的追踪。
所以环绕对安全性来说是非常麻烦的。但就 C 和 C++ 标准而言,这是明确定义的行为。因此你会听到这种说法:“好吧,我们不能捕获(trap)它。我们不能说人们不能这样做,因为它是明确定义的。” 因此,在 CERT C 安全编码标准中,我们明确规定“禁止环绕”,但我们也允许例外。因为确实有些算法场景需要用到它。例如,某些加密算法中会使用模算术。Daniella 昨晚也举了另一个关于信号处理的例子。
是的,所以确实存在这样的场景,虽然非常罕见,但你必须为它们留有余地,必须有例外规定。但在大多数情况下,还是那句话,如果你在计数时发生了环绕,这通常是个错误。所以你确实需要寻找并诊断出这个问题。一个非常好的方法是使用 -fsanitize=unsigned-integer-overflow 编译选项。
这里的一个问题是,如果你要使用这个标志,同时你又设计了像上面那样的向下计数循环。现在这些循环就会被标记报错。这就导致你的诊断工具里出现了误报(false positive)。这是一个 PETA。这里不是指善待动物组织,而是指 Pain In The Ass(蛋疼/麻烦)。这挺搞笑的,因为你为了避免在视频里说“ass(屁股)”而缩写成 PETA,结果你却发现自己不得不解释 PETA 是什么意思,从而在视频里连说了三四次“ass”。
另一个解决方案是使用 do-while 循环。C 程序员以及我猜 C++ 程序员都对 for 循环非常着迷。但是 for 循环其实并没有那么出色。我在这里没有一个特别好的 for 循环的例子,但是 for 循环的问题在于——你有跟在 for 循环后面的循环体。初始化代码在循环之前执行一次,条件代码作为测试执行,然后更新代码在循环体之后执行对吧?所以,这段代码执行的顺序与代码在字面上出现的顺序是不同的。人们经常抱怨其他语言结构有这个问题,但在这里他们似乎视而不见,或者出于某种原因没有注意到。但这确实会让阅读代码变得有点困难,并且人们也因此经常犯错。
所以我们可以写一个 do-while 循环:
if (size == 0) return; // 或者抛出错误
size_t i = size;
do {
--i;
// 循环体
} while (i > 0);
昨晚我在顶部加了一行代码,如果在请求的 size 为 0 时直接报错退出。其余的部分,我们只需要这个 do 循环。我们有一个无符号类型,当我们减到零时就终止循环。这就解决了一切问题,对吧?我们使用了无符号的 size 类型,我们不需要担心任何环绕问题,而且我们不需要进行从有符号到无符号类型的转换,也就避免了可能的实现定义行为和可能的越界值等问题。
零点附近的舒适区与安全边界情况¶
与无符号整数相关的一个也许更普遍的问题是:你可能有一个数组的结束索引(end index)和一个起始索引(start index)。你在检查 end > start,或者为了防止越界写入,检查它是否大于某个安全裕度。安全裕度通常是某个相当小的整数。
这里的问题是,如果程序员未能保证结束索引大于起始索引,这个测试(例如 end - start > margin)可能会因为环绕而失败。所以必须提供某种保证,这种保证可能是由代码本身提供的,或者你必须明确进行测试。
有符号和无符号的运算都有可能出错。无符号类型更大的问题在于它的“问题区域”位于小于零的地方。所以零以下的这个区域,是人们进行操作时容易出问题的常见区域。再次强调,这是支持使用有符号整数的论点。我想声明一点,我并不完全买账这个说法,但这确实是常见的说法——人们遇到无符号类型的问题都在零附近。这就是为什么他们主张使用有符号整数的原因。
而在零附近,有符号整数表现得非常完美。只有在非常偏远的边缘地带(极值处),有符号整数才会成为问题。他们的理论是,我们可能不经常处理这些非常大和非常小的值,所以我们碰上这些问题的频率不高。
然而,这个理论的问题在于:安全性(security)是不管这些的。当你在处理安全性(safety,侧重功能安全)时,你可以看看事情发生的概率——“出现这种极端值的概率有多大?” 但是当你审视安全(security,侧重信息安全/防攻击)时,任何边缘情况(edge case)被触发的概率都应被视为 100%。
因为你要面对的是一个极其聪明的对手,比如我,假设我就是你的对手对吧?如果我在进行渗透测试或攻击你的系统(当然我不会这么做,因为你们都是好人),我会专门寻找那些边缘情况,对吧?我会输入最小和最大范围的值,看看我是否能触发某个你的代码未能考虑到的边界情况。然后看看接下来能搞出什么乐子来。
所以如果你在编程时考虑到了安全性和防攻击性,你就绝不能忽视有符号整数在边缘地带的问题区域,对吧?但倡导有符号整数的人试图把事情简单化,这样那些缺乏经验的程序员就能凭运气多写对一些 C++ 代码。
老实说我真的不在乎新手。这有点奇怪,因为我在 C 语言委员会待了很长时间,我一直注意到他们根本不关心那些天真的、初学的程序员。他们只迎合经验丰富的程序员。但我现在开始明白他们的初衷了,因为我认为最重要的是,你要设计语言和库,使得经验丰富的程序员能够写出正确的代码。我认为这是最关键的。我不认为你应该为了让初学者不会在类型错误上绊倒,而把编写正确代码变得困难。
对我来说,JavaScript 就做了一件极其愚蠢的事情,对吧?你可以拿数字 5 加上字符串 “7”,然后得到 “57”。从用户界面的角度来看,这很好,初学者不懂数字和字符串的区别,在他们眼里长得都差不多。但这会开始引发一些极其棘手的行为,当你在假设存在某种程度的类型安全时,会产生意想不到的结果。
驳斥 Bjarne Stroustrup 与 Google C++ 编程规范¶
好,目前的观察结论是:如果开发人员为了避免去想零附近的环绕行为而使用有符号整数,那么他们绝对也没有在思考极值边缘处的溢出行为。
到目前为止,我们看到的由于使用无符号整数可能导致的问题,通常是像倒数 for 循环变成无限循环这种。这些在测试阶段非常容易被发现,对吧?“哎呀,我的程序不响应了”,这明显是个死循环。
基本上,如果你在编写涉及安全和任务关键型的软件,你绝对不能容忍任何形式的草率编程对吧?所以,当你面对明明应该使用无符号整数的场景时,为了迎合有符号整数的使用,而搞出这样一种方法、这种设计,甚至改变库的 API,对于像 C 或 C++ 这样的语言来说,是朝着完全错误的方向发展。
我以前从没想过我会做这场演讲。我的意思是,从 2004、2005 年起我就一直在讲整数问题。我开始在卡内基梅隆大学(CMU)向计算机科学系的本科生和 INI 项目的研究生教授这些内容。然后,大约一年前,我们试图将 C++ 中的一些位操作实用程序接口(bit utility interfaces)引入 C 语言。结果发现这些接口中大量使用了有符号类型。
我们看着这些东西,心里想:“这他妈的是怎么回事?为什么他们要用有符号类型来告诉我们正在引用哪个比特位?”这明明是一个无符号的值啊!它的取值在 0 到 32 或某个正数之间。
随着我越来越多地看到这种疯狂的现象,我建立了一个关于为什么会发生这种情况的理论。大约一个月前,有人给我发了这个演讲的链接。这是 2013 年 Going Native 大会上的一个小组讨论。这就是我怀疑造成这一切幕后原因的东西——Bjarne(C++之父)曾多次公开表示,并且在他的书中也说过:“只管用 signed int 就行了,到处都用它。”
他的理由是(稍后你们会看到这个说法的重复版本):“在同一个表达式中混合使用有符号和无符号类型,会导致很多问题。”这一点我是同意的。但接下来他却说:“那我们就通过把所有东西都变成有符号的来解决这个问题吧。” 对于这一部分,我坚决反对。在我进一步反驳他之前,让我先展开说说。
昨晚我也提到了这部分,所以我大概在这里也应该说一下。我和 Bjarne 见过几次面。出于某种原因,我们居然还是 Facebook 上的好友。我人生中最让人尴尬(cringiest)的时刻之一就是,一个我高中时的同学在我的 Facebook 评论区和他吵了起来。在整个过程中我真的是尴尬得脚趾抠地。
但是,他终究只是个人,对吧?人是会犯错的,他并不总是对的。问题在于,如果你把某人捧上了天,将他提升到了“神”的地位,这就意味着他不能犯错。于是突然之间,你就拥有一群狂热的追随者。每当这个人说了一些错误的话,他们就必须假装那是对的。然后他们必须创造一个“这件事居然是对的”的新现实。
我们在美国针对特朗普也有同样的现象。他身边有这种狂热的崇拜。他会说类似“我父亲出生在德国”这样的话,而实际上他父亲出生在纽约皇后区。结果没过多久,突然间,皇后区就成了德国的一部分,对吧?因为你必须调整现实来迎合他们的言论。对于一个生态系统来说,建立一个“神”是极其危险的。你必须明白人是会犯错的,你必须为此做好准备。
Google C++ 风格指南(Google C++ Style Guide)基本上采纳并推行了这一切。这是另一份非常高调的文档,人们不仅阅读它、相信它,还被它深深影响。它是这么说的(我基本上是直接粘贴过来的):
首先它说:“无符号整数适用于表示位域(bit fields)和模算术(modulo arithmetic)。” 好的,这一点我完全同意。
第二点它说:“因为一个历史的意外(historical accident),C++ 标准使用无符号整数来表示大小(sizes)。许多标准委员会的成员认为这是一个错误,但在现阶段已经实际上无法修复了。”
不,这不是一个错误! C89 引入了 size_t 类型来表示大小。C 语言委员会里的每一个人都清楚地知道,大小(size)就是一个无符号量(unsigned quantity)。对此没有任何动摇,也没有任何错误!
基本上就是,Bjarne 说“用 int”,然后标准机构里的一些成员——一些极端的崇拜者或马屁精——就不得不把它当作福音并盲从。但指南里也只说了是“许多成员”,甚至没说“大多数成员”,对吧?可是现在,你看到了这种源自 C++ 的疯狂现象在蔓延。我们在 C 语言中收到了这些接口,我们不得不去修改它们,因为它们的定义本来就是错的。
指南在这里也承认了,情况是不会改变的,对吧?每个人都承认,没有办法改变 size_t 类型的符号性质。它永远都是无符号的。C 和 C++ 将永远如此。所以,现在说“让我们把所有的东西都变成 int”,这就是一个错误的选择!这完全违背了现实,对吧?因为我们已经承认大小是无符号的,而且它们将永远是无符号的。那你为什么还要把你新定义的大小变成 int 呢?现在你反而是在加剧“表达式中同时包含有符号和无符号数字”的情况!就把你的大小定义为 size_t,就像上帝和 C 标准委员会所期望的那样。注意我说的是“和”,我可没有把他们当成同一回事。
接下来指南又说什么呢?
“无符号算术并不模拟简单整数的行为,而是被定义为模拟模算术,在溢出和下溢时发生环绕。这意味着这一大类严重错误无法被编译器诊断出来。”
好的,这又纯粹是狗屎(pure bullshit)。
我们刚才说了什么?无符号算术具有模行为(取模)。而有符号整数有什么?有未定义行为(UB),对吧!它们可以干出任何事情。其中它们能干的一件事就是具有模行为,对吧?有符号整数通常会静默地在顶部发生环绕,静默地表现出与无符号数字相同的行为。但它们也可能引发陷阱(trap/崩溃)——许多指令确实会这样。比如在一个非常常见的处理器——Intel 处理器上,特定的指令就会在溢出时触发陷阱。
然后指南说“那一大类严重错误无法被诊断出来”。这又是一些额外的愚蠢言论。这番话实际上在暗示:“嘿,有符号整数的优势在于它们引入了 UB(未定义行为)!所以现在,通过使用有符号整数,你的代码中有了更多的未定义行为,并且因为它是未定义行为,我们就可以在上面设置陷阱,并给你提供更多的信息。”
基本上,这就是在说:通过在你的代码里插入成吨的 Bug,你让你的代码变得更好了。 好的,如果有人跟你这么说,你就直接一巴掌拍他脑袋上。他们肯定是正在经历某种形式的妄想症。这是你所能说出的最愚蠢的话。
我们刚才几分钟前才看过了,有一个专门针对无符号整数的 Sanitizer 标志叫做 unsigned-integer-overflow(无符号整数溢出)。这是一个用词不当的名称,其实应该叫“无符号整数环绕”,但确实有这个标志。所以很显然,工具是可以诊断出它的。即使它是明确定义的行为,你仍然可以诊断它。好吧,(使用这个诊断标志的话)它就不符合标准了,但这不是什么大问题,毕竟你又不会把这个发版,你只是在诊断模式下使用它。一些编译器,比如 IBM XL 编译器,在默认标志下就是不符合标准的。它实际上假设无符号整数不发生环绕,这当然不是标准所规定的。但这并不是什么大不了的事。所以,指南上的那个说法纯属废话。
“在其他情况下,定义好的行为阻碍了优化。” 我拿到过很多这类示例代码,基本上都是在有符号整数中添加额外的代码,由于这些代码会导致 UB,所以被编译器优化掉了(因为编译器可以忽略 UB 发生的可能性)。但是在任何情况下,这些额外代码都是没有必要的。在任何情况下,有符号代码的速度都没有比无符号代码快。所以,这也被证明是一个无效的论点。
指南又说:“尽管如此,混合整数类型的符号性质仍是一大类问题的主要原因。” 好的,再次声明,我同意这是个问题。我只是不同意解决方案。我认为如果你创造更多的有符号类型,你只会增加这个问题。因为 size 类型是不会改变的,sizeof 永远会返回一个 size_t 类型,一个无符号类型。你无法改变这一点。我同意混合使用是个问题,但我认为在应该使用无符号类型时去创造更多的有符号类型,只是在加剧这个问题。
“我们能提供的最佳建议是:尽量使用迭代器和容器,而不是指针和大小;尽量不要混合符号性质……” 这些我都同意。
除了下面这句:“尽量避免使用无符号整数。” 我的意思是,指南前面刚说无符号整数应该专用于位域和模算术,我部分同意。但这句“尽量避免使用它们”简直是无稽之谈。
“不要仅仅为了断言一个变量是非负的而使用无符号类型。” 这正是我们刚才在测验里问大家的。在我们刚才做的三个投票里,绝大多数开发人员都不是傻瓜对吧?你们都知道,这(表示非负)完全就是你们使用无符号数字的原因。所以 Google 的这条建议违背了常理,违背了一切神圣的东西。这是极其令人尴尬的。Google 是一家拥有许多聪明人的伟大公司,而他们却在发布垃圾建议。这也是人们盲从的东西。他们在积极地把事情弄得更糟,糟糕得多。
许多漏洞都是通过代码插桩(instrumentation)和模糊测试(fuzzing)结合发现的。例如,使用 UBSan 的 signed-integer-overflow 可以诊断出有符号整数溢出。有符号整数的计算是可以被诊断的,包括使用 -ftrapv 标志(针对带符号除法溢出)。但它不包括会造成精度丢失的隐式转换。
这里我们有一个使用“无符号整数溢出” Sanitizer 的例子。虽然它是个术语误用,应该叫“无符号整数环绕”,但不管怎样,这里我们有一个无符号的 uint32_t 类型,这里发生了环绕,Sanitizer 非常完美地诊断出了这个问题,对吧?所以,所谓“这些问题无法被诊断”的观点,在证据面前是不攻自破的。
驳斥 Chandler Carruth 的性能论点¶
当我第一次开始意识到人们对如何使用有符号和无符号类型存在这些误解时,我问别人:“这都是哪儿来的?” 人们首先让我看的是 Chandler Carruth 的这场演讲(他可能就在这次会议上)。我先看了这个,之后才看了 Bjarne 三年前的那个演讲。
Chandler 讨论了有符号和无符号整数的性能。他使用了 SPEC 性能测试集里的一段代码,我想是 bzip 的基准测试。大约 10 年前,我曾接了一个外包私活,使用手动分析的方法来分析这段代码,看看是否能检测出潜在的缓冲区溢出。所以我对这段代码相当熟悉。
Chandler 指出的是,在一个 64 位架构上,我们使用了 uint32_t 类型的参数 i1 和 i2 来形成一个地址。所以这变成了一个 64 位的操作。但是之后我们又在对 i1 和 i2 进行递增操作,在这种语境下,不会发生提升(promotions)什么的,所以这个操作必须是一个 32 位的操作。因为我们同时有一个 64 位操作和一个 32 位操作,所以代码这里产生了一些冗余。必须生成一些额外的指令,这使得代码稍微变慢了一点。
那么现实情况是怎样的呢?我拿了这段代码并进行了测试。在 64 位架构上,使用了 size_t 类型,并开启 -O3 优化。结果发现,size_t 在 GCC、ICC 和 Clang 上都产生了最快的代码!所有这三种情况下,只要你在这里使用了正确的类型 size_t,就能产生最快的代码。怎么样?你用了正确的类型,它就是最快的。
有符号的 int32_t 和无符号的 uint32_t 的相对性能取决于具体的编译器,但在所有情况下,它们都比 size_t 差。所以,使用正确类型的无符号整数会产生最快的代码,对吧?我只是测试了这个说法,结果它同样是不正确的。
所以我在 Twitter 上向 Chandler 发送了测试结果。他是这么回复的:
“在此澄清一下,那是一个 6 年前的演讲。编译器实际上在这个特定领域已经发生了重大改变。这也是一个非常脆弱的领域。回想起来,我应该使用一个更耐得住时间考验的例子,但至今还没有人给我展示过一个更好的。我的初衷从来不是为了引入 UB,但大家却忽略了这一点。我想我原本想表达的观点并没有传达清楚。我希望大家别再引用那场演讲了。”
我要把这段话定性为对那场演讲的声明放弃(disavowal)。他正在远离那个关于“有符号整数更快”的论点和主张。所以如果你听过那场演讲并相信了他,他现在告诉你别信了,对吧?他已经宣布放弃那场演讲了。
为什么无符号整数更合理:未定义行为与编译器优化¶
所以让我们来谈谈为什么无符号整数更合理。
有符号整数溢出在 C 标准中是未定义行为(UB)。实现方案可以选择静默环绕(最常见的行为),可以选择触发陷阱(trap),也可以混合处理。比如我最熟悉的是英特尔架构。在英特尔架构上,加法、减法、乘法会静默环绕,而除法和求余运算会触发陷阱。
这里有一个有趣的“戏法”:INT_MIN % -1(最小负整数求余负一)。从数学上讲,这应该产生 0。但在英特尔处理器上它实际上会触发崩溃。因为它在底层被实现为一个除法操作,而这个除法操作的结果发生了溢出,这在 x86 架构上会导致严重错误(fault)。所以,你会发现一些操作像无符号数字一样静默环绕,而另一些则会触发崩溃。
我从没写过编译器,也许你们当中有人写过。但基本上有三种基础的实现策略来构建编译器:
硬件行为模型(Hardware behavior model):你只管生成对应的汇编代码,让硬件去执行硬件本来的行为。对于在座比较成熟(mature)的人——我不说老(old),我说成熟的人——这就是多年来普遍采用的策略,是我们中许多人习以为常的模式。
超级调试模型(Super debug model):为了提供密集的调试环境,你试图捕获几乎所有的行为。比如 Address Sanitizer 和 UBSan 就是我们捕获未定义行为的例子。这会严重降低性能,所以它通常不用于生产代码或部署软件。
全权许可模型(Total license model):将未定义行为视为“绝对不可能发生的情况”。这就允许非常激进的优化。
当然,我看到的一个关于编译器的现象是,编译器作者其实没有什么原则性可言。你很难看到一个纯粹的硬件行为模型或纯粹的全权许可模型。比如你拿两个看起来几乎一模一样的循环交给 GCC,其中一个它会基于硬件行为模型编译,而另一个它会基于全权许可模型编译。
编译器供应商与他们的代码库之间有一种相互妥协的关系。编译器的行为被调整过,以适应那些旧代码;而人们为了特定编译器编写的代码也被微调过,好让编译器能顺利处理。这很正常。编译器只是在试图帮助他们的用户群体。这对可移植性(portability)来说并不棒,但 C 和 C++ 本来就不是为了成为高度可移植的语言而设计的。它们的设计初衷是为了能为特定的目标架构编写出具有最佳效率的代码。这是它的主要目标。C 语言章程里就是这么说的,基本上,“最佳的效率优先于可移植性”。任何使用过它的人都知道这一点,这很明显。
Java 是一个旨在跨平台、具有可移植性的语言。其后果就是,在过去 30 年里,大概只有两个桌面应用程序是用 Java 写的,因为它实在太他妈慢了。这两种都是很好的语言,它们有不同的设计需求。如果你把 C 语言制定者的那些需求交还给那群聪明人,你自然就会得到 C 语言这套体系。
好的。这里有一个整数溢出的例子。这里有一小段代码,它接收一个有符号整数 i,测试它是否大于 0,然后将 i 的值翻倍(i * 2)。接着递增一个计数器。所以这段代码的作用就是计算:在我将 i 翻倍多少次之后,它会发生环绕变为负数,对吧?
但你在这段代码中看到的是一个假设——它假设会发生环绕。但(有符号整数)溢出是未定义行为。编译器知道这是未定义行为,所以它可以选择忽略它。如果用 GCC 编译这段代码,它确实使用了全权许可模型(即认为 UB 不可能发生)。当它看到这段代码时,它会想:“他们正在不断翻倍一个正数,直到它变成负数。这意味着他们假设整数范围是无限的,可以永远翻倍,环绕不可能发生。”于是编译器就断定:“哦,他们想要一个无限循环。” 最终它为这段代码生成了一个无限循环。这是对这个未定义行为的一种完全有效的解释!因为这是 UB,它想怎么干就怎么干,它觉得你就是这个意思。
有符号整数在除法和求余中的陷阱¶
如果我们看看那些会发生环绕的运算符,你会发现大部分都有可能。
(调整麦克风)我简直要放弃这个头戴式麦克风了。我的头发有点太多、太蓬松了,这东西老是弹出去。
你会看到,大多数操作符都可能导致环绕。唯二不可能导致无符号类型发生环绕的是除法和求余。然而,当你考察溢出(overflow,指有符号)时,这两个运算符都会发生溢出!原因是在二进制补码表示中,你拥有的负数值比正数值多一个。比如 signed char 的范围是 -128 到 127。所以如果你对最负的值取反,它是无法在该类型中表示的。
如果你用 INT_MIN 除以 -1,这就产生了溢出。这个操作的结果是无法表示的。同样,如果你对 INT_MIN 使用一元取反操作符(unary negation),也是无法表示的。
我前面已经提到了求余的问题。INT_MIN % -1,数学上应该产生 0,但因为它使用了除法指令,所以也会产生严重错误(fault)。这也是未定义行为。
所以仅仅在这两张幻灯片里我们就看到了,有符号整数存在更多的问题,有更多容易出错的地方。这只是多出来的两点,但(相比无符号整数)它的问题还多得多。我们之前已经看到了类型转换也是一个问题。
总结一下,当你在用一个整数除以 -1,特别是除数具有 int 或更大尺寸时,你就会遇到除法溢出。如果是较小的类型比如 short,整数提升(integer promotions)会防止溢出,因为它会被提升到一个更大的尺寸,在那里结果值是可以表示的。但对于 int 大小及以上的类型,你就会得到溢出。在 X86 上会导致除法错误,并在中断向量 0(interrupt vector zero)上产生故障。
关于求余,挺有趣的是,很多人以为那是“模运算符(modulo operator)”。但 C 标准里根本没提它是什么。不过你能看出来它是作为除法的一部分来定义的,所以它很明显是“求余(remainder)”。“求余”这个词唯一一次出现是在这个操作符的索引页面里,指引你翻到正确的一页。如果没有这点小提示,这个操作符的名字根本就不会被提及。
如果你实际测试 INT_MIN % -1,因为它可以作为除法操作的一部分被实现,所以你在求余期间同样会得到溢出。当然,如果你知道了这一点,你做的第一件事就是去写个测试代码敲进去,然后它返回给你一个 0。然后你说:“哦,我的处理器处理这个没问题啊。” 但问题是,这是被预处理器常量折叠(constant folded)了。所以你必须使用变量并动态提供信息,以确保生成了实际的汇编指令,你的测试才是有效的。
那么我们在这里学到了什么?显然是不要和穆罕默德·阿里去打拳击。(指幻灯片配图)
但更重要的是:对于除法和求余操作,有符号整数的表现与无符号整数一样糟糕,甚至更糟。它们更难以处理。你需要考虑更多的边缘情况。每一次你处理两个不受信任的输入进行除法时,你都必须检查它是否是 INT_MIN 和 -1的组合。你必须针对这个边界情况进行测试,否则你的代码就会崩溃。
安全运算的成本对比¶
有一个叫 Dave LeBlanc 的家伙。他曾在微软工作,后来离开了,又回去了,现在在 Facebook。他还去过一家创业公司,倒闭后就去了 Facebook。他编写了随 Microsoft Visual Studio 一起发布的 SafeInt C++ 库,它也适用于其他平台,并且最近还实现了 C 语言版本的库。
他给我发了这段代码,这是他写的代码。显然,他实际上就这个问题与 Bjarne 直接进行过争论,而我没有,我只是在看他公开发表的言论。
这是一个两个有符号数字相加的例子。根据 Dave 的说法,为了正确且安全地执行有符号整数的加法,你需要三个分支(branches)和一个额外的减法操作。而无符号加法则非常简单:没有任何浪费的指令。这里只有一个比较操作(比较结果与操作数的大小),你就可以测试出你是否得到了一个有效的结果。
我也有一个关于乘法的例子,但道理是一样的。如果你想编写安全、可靠的整数运算,使用有符号类型总是比无符号类型要昂贵得多!这也是为什么我们应该更偏爱无符号类型的一个极其重要的原因。
这其实就像是在争论:如果你不介意你的代码是错误的,那么有符号整数用起来可以更快。但如果你想编写正确的代码,无符号整数总是更快的!如果你根本不在乎你的代码到底对不对,那我真的不知道该怎么跟你交流了。你大可以直接写一句 return x;,嘿,虽然是错的,但你瞬间写完了。我无法理解这种逻辑。大家大概都是想编写正确代码的,尤其是涉及到安全防范和功能安全问题的时候。所以,检查无符号的环绕问题,总是比检查有符号的溢出问题更容易,也更快。
我认为右边那张图里的人是桑尼·利斯顿(Sonny Liston)。有人认识吗?我其实没老到能记住这场比赛的程度,但我确实记得穆罕默德·阿里的拳击比赛。
结论与现场问答¶
不管是使用有符号还是无符号数字,都有可能发生错误。没有什么是绝对没有问题的。但是有符号整数的失败方式更多。你可以用有符号整数写出正确的代码,如果你真这么干了,我会很高兴。但是当你使用有符号整数时,让它们正确运行的成本更高。所以它将会更慢。这与 Chandler Carruth 所说的完全相反:如果要正确使用的话,有符号整数总是会更慢的。
向程序中注入未定义行为(UB)并不会使它更安全、更可靠。只会让你看起来很愚蠢。不是吗?“哦,我要在我的系统里注入一大堆未定义行为”,然后这居然是一件好事?这纯粹是胡说八道。当然,MISRA 规范也说了:禁止 UB。所以这根本不符合 MISRA 标准。
如果你试图正确、安全、可靠地编程,无符号整数总是成本更低的。
然而在 C++ 开发者中存在一种普遍的误解。我甚至不想称之为“误解”了,我认为这是一种“洗脑”。他们听过 Bjarne 说“用 signed int”,所以他们不知怎么就让自己的大脑认定那就是现实。他们现在正处于一种错觉(illusion)之中——对,错觉就是这个词——他们错误地认为有符号整数应该作为默认类型,到处都应该使用,而且你也不需要模算术。
事实恰恰相反,你应该尽量最大化地使用无符号整数。每当你有一个绝对不会出现负值的类型,比如任何类型的计数(count),或者大小(size),它确实就应该用无符号类型来表示。特别是大小(size)!因为在 C 和 C++ 标准的内建机制里,有太多东西都会返回无符号类型的大小了,你根本绕不开它。
我的时间掐得刚好,因为我……
观众提问: 是的,你列了 10 点,但我……
Robert: 对,我先回答一个。如果你有一个变量,假设它的取值范围是从 0 到 255。按照可移植的写法,你可以将其声明为 unsigned char,对吧?但我通常不会这么做。原因在于……我会把它声明为 unsigned int。虽然这浪费了三个字节,但是对于小型类型(small types)的问题在于它们会经历整数提升(integer promotions)。所以一个 unsigned char 会被提升为 signed int。在大多数架构上都是如此。
整数提升带来的问题是,它会在你的代码中引入实现定义行为(implementation-defined behavior)。根据不同架构的不同,一个小型整数可能会被提升为 signed int,也可能会被提升为 unsigned int。所以,如果我只是处理一个一次性的单独变量,并且我对存储空间没有太大的顾虑,那么我绝对不会使用小于 unsigned int 或 signed int 的类型。
当然,如果我必须要有一个包含 10,000 个元素的数组,范围是 0 到 255。那好,我会用 unsigned char,因为这涉及到大量的存储开销。但在这种情况下,你可能需要使用静态断言(static assertions)来记录你的代码针对实现定义行为的某些假设前提,或者你在写代码时必须极其小心。有一些非常诡异的情况,比如如果你将两个最大的 unsigned short 值相乘,它会变成一个 signed int 并发生溢出。不像在 x86 架构上,short 是 16 位而 int 是 32 位(不会溢出)。然后因为这是 UB,你就会得到各种稀奇古怪的行为。所以对于小于 int 或 signed int 的小型类型,确实存在一些麻烦的边界情况。是的,如果它只是个单独的变量,我永远不会声明任何小于 int 的类型。
还有问题吗?你?
观众提问: 设置未定义行为或者有符号操作的初衷和理由是什么?为什么不直接规定它们应该做什么呢?
Robert: UB 的存在有各种各样的原因。我在这里要稍微猜测一下,因为就像我说的,我还没老到那个地步。很多这些决定早在我加入之前就已经做出了。我是 2004 年加入 C 语言委员会的,在那儿待了大概 20 年。但很多决定在这之前就有了。
将某个行为声明为 UB 的原因之一是因为诊断该行为很难。不过我认为在这个特定问题上,这不是主要原因。
第二个原因是因为不同的底层实现(硬件)做事的方式确实不同。C 标准不想强制规定采用某一种特定的方法。所以他们把它变成 UB,以允许不同实现方案之间存在差异。比如,无符号整数 UINT_MAX % -1 (更准确地说是之前提到的除法溢出等场景),把它设为 UB 是因为一些实现如果把求余指令和除法指令分开了,它会产生一个 0。但有些实现会引发错误(fault)。标准把它设定为 UB 的原因是,如果他们不这么做,你的结果就会变得跟 Java 一样。
Java 规定 INT_MIN % -1 结果就是 0。所以在英特尔处理器上跑 Java 编译器编译出来的代码,每次你执行一个普通的除法操作时,你都必须额外编写代码去检查这个极端的边界情况,并强制产生一个 0。你不得不在正常的除法指令里插入额外的判断分支。这将显著拖慢你的代码运行速度。而这就完全背离了 C 和 C++ 的精神。C 和 C++ 存在的意义就是为特定的架构编写优化过的代码,并将确保正确的重担大幅度地转移给程序员。这样回答了你的问题吗?好的。因为等我说到最后,我都快忘了开头的问题是什么了。
我听到过有人问:“为什么他们不叫它‘实现定义行为(implementation-defined)’,而是叫 UB,从而允许编译器直接删掉代码?”我刚才演讲时也在想这个问题。但这绝对是很多事物被定义为 UB 的一个主要原因类别。
我干脆回答一个你们还没问的问题吧:“为什么他们现在不改掉它?”
他们永远不会改变它的原因,是因为编译器优化器(optimizers)在过去的 30 年里,围绕着“有符号整数溢出是 UB”这一事实,想出了数不清的聪明绝顶的优化手段!如果标准把它定义为环绕,或者定义为“实现定义行为”,他们将失去所有的这些优化效果。因为他们就不能直接假设 UB 不会发生了。
所以它现在已经深深地烙印在了这些语言的基因里,因为大家在优化上投入了太多。编译器供应商是永远不会放弃它的。虽然我把编译器供应商说得很糟糕,但真正的问题是——用户永远不会放弃它!如果编译器供应商取消了这些优化,你的代码在一夜之间变慢了 50%,你绝对会气得拉砖头(shit a brick),然后在电话里冲他们破口大骂,逼着他们改回去。供应商知道这一点。现实的残酷之处就在于,这仍然是一个性能驱动的世界。性能优先于网络安全、功能安全以及所有其他的事情。这一点至今也没有改变。
还有跟进的问题吗?
观众提问: 你说无符号类型也有问题。但我觉得我们也许可以从有符号阵营那边提取一些简单的规则或者简单的解决方案……这些是不是在无符号类型中没差别?
Robert: 是的,我认为有符号和无符号的问题都是可以解决的。我只是认为有符号整数存在更多的问题,并且解决这些问题的成本(代价)要高得多。所以结论就是——无符号类型胜出!
好的,谢谢大家。等会儿我冲出大楼的时候,请千万别挡我的道。谢谢,谢谢大家!