在 Google C++ Style Guide 中,关于“无符号整数”的主题,建议
由于历史意外,C++ 标准也使用无符号整数来表示容器的大小——标准组织的许多成员认为这是一个错误,但实际上目前无法修复。无符号算术不模拟简单整数的行为,而是由标准定义以模拟模算术(围绕上溢/下溢)这一事实,这意味着编译器无法诊断出一类重要的错误。
模运算有什么问题?这不是无符号整数的预期行为吗?
该指南提到了什么样的错误(一个重要的类)?溢出错误?
不要仅仅使用无符号类型来断言变量是非负的。
我可以考虑使用有符号整数而不是无符号整数的一个原因是,如果它确实溢出(为负数),则更容易检测到。
unsigned int x = 0; --x;
,看看x
会变成什么。如果没有限制检查,大小可能会突然得到一些意外的值,很容易导致 UB。
int
上溢和下溢是 UB。与将 unsigned int
递减到零以下的情况相比,您不太可能遇到 int
会尝试表达它无法表达的值的情况,但会遇到对 unsigned int
的行为感到惊讶的人算术是那种也可以编写会导致 int
溢出相关 UB 的代码的人,比如使用 a < a + 1
来检查溢出。
这里的一些答案提到了有符号和无符号值之间令人惊讶的提升规则,但这似乎更像是一个与混合有符号和无符号值有关的问题,并且不一定解释为什么在混合场景之外有符号变量比无符号变量更受欢迎。
根据我的经验,除了混合比较和提升规则之外,无符号值是错误磁铁的主要原因有两个,如下所示。
无符号值在零处不连续,这是编程中最常见的值
无符号和有符号整数在它们的最小值和最大值处都有一个不连续性,它们会环绕(无符号)或导致未定义的行为(有符号)。对于 unsigned
,这些点位于 zero 和 UINT_MAX
。对于 int
,它们位于 INT_MIN
和 INT_MAX
。具有 4 字节 int
值的系统上 INT_MIN
和 INT_MAX
的典型值是 -2^31
和 2^31-1
,而在这样的系统上 UINT_MAX
通常是 2^32-1
。
不适用于 int
的 unsigned
的主要错误诱导问题是它在零处具有不连续性。当然,零是程序中非常常见的值,还有其他小的值,如 1、2、3。在各种构造中添加和减去小值是很常见的,尤其是 1,如果你从 unsigned
值中减去任何东西并且它恰好为零,你就会得到一个巨大的正值和一个几乎肯定的错误。
考虑代码按索引遍历向量中的所有值,除了 last0.5:
for (size_t i = 0; i < v.size() - 1; i++) { // do something }
这工作正常,直到有一天你传入一个空向量。您将获得 v.size() - 1 == a giant number
1 而不是进行零次迭代,并且您将进行 40 亿次迭代并且几乎存在缓冲区溢出漏洞。
你需要这样写:
for (size_t i = 0; i + 1 < v.size(); i++) { // do something }
所以在这种情况下可以“修复”它,但只能通过仔细考虑 size_t
的无符号性质。有时您无法应用上述修复,因为您想要应用一些可变偏移量而不是常量偏移量,这可能是正数或负数:因此您需要将其放在比较的哪个“方面”取决于签名- 现在代码变得真的混乱了。
尝试迭代到零(包括零)的代码也存在类似的问题。 while (index-- > 0)
之类的东西可以正常工作,但显然等效的 while (--index >= 0)
永远不会因无符号值而终止。当右侧 literal 为零时,您的编译器可能会警告您,但如果它是在运行时确定的值,则肯定不会。
对位
有些人可能会争辩说有符号值也有两个不连续性,那么为什么要选择无符号值呢?不同之处在于两个不连续性都非常(最大)远离零。我真的认为这是一个单独的“溢出”问题,有符号和无符号值都可能在非常大的值处溢出。在许多情况下,由于值的可能范围受到限制,溢出是不可能的,并且许多 64 位值的溢出在物理上可能是不可能的)。即使可能,与“零”错误相比,与溢出相关的错误的可能性通常很小,并且无符号值也会发生溢出。所以 unsigned 结合了两全其美:可能会溢出非常大的幅度值,以及零处的不连续性。签名的只有前者。
许多人会与未签名争论“你失去了一点”。这通常是正确的 - 但并非总是如此(如果您需要表示无符号值之间的差异,无论如何您都会丢失该位:这么多 32 位的东西无论如何都限制为 2 GiB,或者您会有一个奇怪的灰色区域说一个文件可以是 4 GiB,但你不能在第二个 2 GiB 的一半上使用某些 API)。
即使在未签名的情况下对你有一点好处:它对你没有多大好处:如果你必须支持超过 20 亿个“东西”,你可能很快就会不得不支持超过 40 亿个。
从逻辑上讲,无符号值是有符号值的子集
在数学上,无符号值(非负整数)是有符号整数的子集(仅称为_整数)。2。然而,有符号值自然会从仅针对无符号值的运算中弹出,例如减法。我们可能会说无符号值不会在减法下关闭。有符号值并非如此。
想在文件中找到两个无符号索引之间的“增量”吗?那么你最好按正确的顺序做减法,否则你会得到错误的答案。当然,您经常需要运行时检查来确定正确的顺序!在将无符号值作为数字处理时,您通常会发现(逻辑上)有符号值始终会出现,因此您不妨从有符号开始。
对位
如上面脚注 (2) 所述,C++ 中的有符号值实际上并不是相同大小的无符号值的子集,因此无符号值可以表示与有符号值相同数量的结果。
是的,但范围不太有用。考虑减法,范围为 0 到 2N 的无符号数,以及范围为 -N 到 N 的有符号数。在 _both 情况下,任意减法的结果都在 -2N 到 2N 范围内,并且任一类型的整数只能表示一半。事实证明,以 -N 到 N 的零为中心的区域通常比 0 到 2N 的范围更有用(在现实世界代码中包含更多实际结果)。考虑除均匀分布(log、zipfian、正态等)以外的任何典型分布,并考虑从该分布中减去随机选择的值:在 [-N, N] 中的值比在 [0, 2N] 中的值更多(实际上,结果分布总是以零为中心)。
64 位关闭了使用无符号值作为数字的许多原因
我认为上面的论点对于 32 位值已经很有说服力,但是对于 32 位值,确实会发生在不同阈值下影响有符号和无符号的溢出情况,因为“20 亿”是一个可以被许多人超过的数字抽象和物理量(数十亿美元,数十亿纳秒,具有数十亿元素的阵列)。因此,如果有人对无符号值的正范围加倍足够确信,他们可以证明溢出确实很重要,并且它稍微有利于无符号。
在专用域之外,64 位值在很大程度上消除了这种担忧。带符号的 64 位值的上限为 9,223,372,036,854,775,807 - 超过九个五分之一。那是很多纳秒(大约值 292 年)和很多钱。它也是一个比任何计算机都更大的阵列,很可能在一个连贯的地址空间中长时间拥有 RAM。所以也许 9 quintillion 对每个人来说都足够了(现在)?
何时使用无符号值
请注意,样式指南并不禁止甚至不一定阻止使用无符号数字。它的结尾是:
不要仅仅使用无符号类型来断言变量是非负的。
事实上,无符号变量有很好的用途:
当您不想将 N 位数量视为整数,而只是将其视为“位袋”时。例如,作为位掩码或位图,或 N 个布尔值或其他。这种用法通常与 uint32_t 和 uint64_t 等固定宽度类型密切相关,因为您经常想知道变量的确切大小。一个特定变量值得这种处理的提示是,您只能使用按位运算符(如 ~、|、&、^、>> 等)对其进行操作,而不能使用算术运算(如 +、-、*)对其进行操作。 , / 等等。无符号在这里是理想的,因为按位运算符的行为是明确定义和标准化的。有符号值有几个问题,例如移位时未定义和未指定的行为,以及未指定的表示。
当你真正想要模运算时。有时你实际上想要 2^N 模运算。在这些情况下,“溢出”是一个特性,而不是一个错误。无符号值在这里为您提供您想要的东西,因为它们被定义为使用模运算。有符号值根本不能(容易、有效地)使用,因为它们具有未指定的表示形式并且溢出是未定义的。
0.5 写完这篇文章后,我意识到这与我从未见过的 Jarod's example 几乎相同 - 并且有充分的理由,这是一个很好的例子!
1 我们在这里讨论的是 size_t
,所以通常在 32 位系统上为 2^32-1 或在 64 位系统上为 2^64-1。
在 C++ 中,情况并非完全如此,因为无符号值在上端包含的值比相应的有符号类型多,但存在操作无符号值可能导致(逻辑上)有符号值的基本问题,但没有相应的问题有符号值(因为有符号值已经包含无符号值)。
如前所述,混合 unsigned
和 signed
可能会导致意外行为(即使定义明确)。
假设您要遍历 vector 的所有元素,除了最后五个,您可能会错误地写:
for (int i = 0; i < v.size() - 5; ++i) { foo(v[i]); } // Incorrect
// for (int i = 0; i + 5 < v.size(); ++i) { foo(v[i]); } // Correct
假设 v.size() < 5
,则由于 v.size()
是 unsigned
,s.size() - 5
将是一个非常大的数字,因此对于更预期的 i
值范围,i < v.size() - 5
将是 true
。然后 UB 很快发生(一次 i >= v.size()
越界访问)
如果 v.size()
将返回有符号值,则 s.size() - 5
将为负数,在上述情况下,条件将立即为假。
另一方面,索引应该在 [0; v.size()[
之间,所以 unsigned
才有意义。 Signed 也有其自身的问题,即 UB 具有溢出或实现定义的负符号数右移行为,但迭代的错误来源较少。
i+X<size()
而不是 i<size()-X
。当然,这是一件要记住的事情,但在我看来,习惯并不难。
最令人毛骨悚然的错误示例之一是当您混合有符号和无符号值时:
#include <iostream>
int main() {
auto qualifier = -1 < 1u ? "makes" : "does not make";
std::cout << "The world " << qualifier << " sense" << std::endl;
}
输出:
世界没有意义
除非你有一个简单的应用程序,否则你不可避免地会在有符号和无符号值之间出现危险的混合(导致运行时错误),或者如果你发出警告并使其成为编译时错误,你最终会得到很多代码中的 static_casts。这就是为什么最好严格使用有符号整数作为数学或逻辑比较的类型。仅对位掩码和表示位的类型使用无符号。
根据数字值的预期域将类型建模为无符号是一个坏主意。大多数数字更接近 0 而不是 20 亿,因此对于无符号类型,您的许多值更接近有效范围的边缘。更糟糕的是,最终值可能在已知的正范围内,但是在评估表达式时,中间值可能下溢,如果它们以中间形式使用,则可能是非常错误的值。最后,即使您的值预计始终为正,这并不意味着它们不会与其他可能为负的变量交互,因此您最终会遇到强制混合有符号和无符号类型的情况,即最糟糕的地方。
为什么使用无符号整数比使用有符号整数更容易导致错误?
使用无符号类型不会比使用有符号类型来处理某些任务类更容易导致错误。
为工作使用正确的工具。
模运算有什么问题?这不是无符号整数的预期行为吗?为什么使用无符号整数比使用有符号整数更容易导致错误?
如果任务匹配的话:没有错。不,可能性不大。
安全、加密和身份验证算法依赖于无符号模块化数学。
压缩/解压缩算法以及各种图形格式也受益,并且对无符号数学的错误更少。
任何时候使用按位运算符和移位,无符号运算都不会与有符号数学的符号扩展问题混淆。
有符号整数数学具有直观的外观和感觉,包括编码学习者在内的所有人都很容易理解。 C/C++ 最初不是目标语言,现在也不应该是一种介绍性语言。对于使用有关溢出的安全网的快速编码,其他语言更适合。对于精益快速代码,C 假设编码人员知道他们在做什么(他们是有经验的)。
当今有符号数学的一个缺陷是无处不在的 32 位 int
,它有很多问题,对于没有范围检查的常见任务来说足够宽。这导致了溢出没有被编码的自满情绪。相反,for (int i=0; i < n; i++)
int len = strlen(s);
被视为 OK,因为 n
被假定为 < INT_MAX
和字符串永远不会太长,而不是在第一种情况下受到全面保护,或者在第二种情况下使用 size_t
、unsigned
甚至 long long
。
C/C++ 开发的时代包括 16 位和 32 位 int
,无符号 16 位 size_t
提供的额外位意义重大。需要注意溢出问题,无论是 int
还是 unsigned
。
由于 Google 在非 16 位 int/unsigned
平台上的 32 位(或更宽)应用程序,考虑到 int
的范围足够大,因此缺乏对 +/- 溢出的关注。这对于此类应用程序鼓励 int
而不是 unsigned
是有意义的。然而 int
数学并没有得到很好的保护。
狭窄的 16 位 int/unsigned
问题适用于今天的选定嵌入式应用程序。
Google 的指导方针非常适用于他们今天编写的代码。对于更大范围的 C/C++ 代码,它不是一个明确的指导方针。
我可以考虑使用有符号整数而不是无符号整数的一个原因是,如果它确实溢出(为负数),则更容易检测到。
在 C/C++ 中,有符号 int 数学溢出是未定义的行为,因此肯定比未定义的无符号数学行为更容易检测。
正如 @Chris Uzdavinis 所评论的那样,所有人(尤其是初学者)最好避免混合 signed 和 unsigned,否则在需要时仔细编码。
int
也不模拟“实际”整数的行为。溢出的未定义行为不是数学家如何看待整数:抽象整数不可能“溢出”。但这些是机器存储单元,而不是数学家的数字。
signed int
溢出很容易检测(使用 -ftrapv
),而无符号“溢出”很难检测。
我对谷歌的风格指南有一些经验,也就是很久很久以前进入公司的糟糕程序员的疯狂指令的搭便车指南。这个特定的指导方针只是那本书中数十条疯狂规则的一个例子。
如果您尝试对无符号类型进行算术运算(参见上面的 Chris Uzdavinis 示例),换句话说,如果您将它们用作数字,则只会在无符号类型上发生错误。无符号类型不打算用于存储数字量,它们旨在存储诸如容器大小之类的计数,它永远不能为负,它们可以而且应该用于该目的。
使用算术类型(如有符号整数)来存储容器大小的想法是愚蠢的。你也会使用双精度来存储列表的大小吗? Google 有人使用算术类型存储容器大小并要求其他人做同样的事情,这说明了该公司的一些情况。我注意到这些指令的一件事是,它们越笨,就越需要严格的“做它或你被解雇”规则,否则有常识的人会忽略该规则。
unsigned
类型只能保存计数而不能用于算术,那么所做的一揽子语句实际上会消除按位运算。因此,“来自不良程序员的疯狂指令” 部分更有意义。
int
是 16 位的日子里很常见,但今天少得多),最好让计数表现得像数字.
使用无符号类型表示非负值...
当使用有符号和无符号值时,更有可能导致涉及类型提升的错误,正如其他答案所展示和深入讨论的那样,但是
不太可能导致涉及选择具有能够表示不受欢迎/不允许值的域的类型的错误。在某些地方,您会假设该值在域中,并且当其他值以某种方式潜入时,可能会出现意外和潜在的危险行为。
谷歌编码指南强调第一种考虑。其他指南集,例如 C++ Core Guidelines,更加强调第二点。例如,考虑核心准则 I.12:
I.12:将不能为 null 的指针声明为 not_null 原因 帮助避免取消引用 nullptr 错误。通过避免对 nullptr 进行冗余检查来提高性能。示例 int length(const char* p); // 不清楚 length(nullptr) 是否有效 length(nullptr); // 好的?整数长度(not_null
当然,您可以争论整数的 non_negative
包装器,它可以避免这两类错误,但这会有其自身的问题......
google 声明是关于使用 unsigned 作为容器的大小类型。相比之下,这个问题似乎更笼统。在您继续阅读时,请记住这一点。
由于到目前为止大多数答案都是对 google 声明的反应,而对更大的问题则更少,我将开始我的关于负容器大小的答案,然后尝试说服任何人(绝望,我知道......)unsigned 是好的。
签名的容器大小
让我们假设有人编写了一个错误,导致容器索引为负。结果是未定义的行为或异常/访问冲突。当索引类型未签名时,这真的比获得未定义的行为或异常/访问冲突更好吗?我想不是。
现在,有一类人喜欢谈论数学以及在这种情况下什么是“自然”的。具有负数的整数类型如何自然地描述本质上> = 0的事物?大量使用负大小的数组?恕我直言,特别是数学倾向的人会发现这种语义不匹配(大小/索引类型表示负数是可能的,而负数数组很难想象)令人讨厌。
因此,在这个问题上剩下的唯一问题是——如谷歌评论中所述——编译器实际上是否可以积极协助发现此类错误。甚至比替代方案更好,这将是下溢保护的无符号整数(x86-64 程序集和可能其他架构有实现这一点的方法,只有 C/C++ 不使用这些方法)。我能理解的唯一方法是编译器是否自动添加了运行时检查 (if (index < 0) throwOrWhatever
),或者在编译时操作会产生很多潜在的误报警告/错误“此数组访问的索引可能为负”。我有疑问,这会有所帮助。
此外,实际上为数组/容器索引编写运行时检查的人更多在处理有符号整数。您现在必须写:if (index >= 0 && index < container.size()) { ... }
,而不是写 if (index < container.size()) { ... }
。在我看来像是强迫劳动,而不是改善...
没有无符号类型的语言很糟糕......
是的,这是对 java 的一次尝试。现在,我来自嵌入式编程背景,我们在现场总线上工作了很多,其中二进制操作(和,或,异或,...)和值的按位组合实际上是面包和黄油。对于我们的一种产品,我们——或者更确切地说是客户——想要一个 Java 端口……而我坐在对面,幸运的是,那个非常能干的人做了这个端口(我拒绝了……)。他试图保持镇定……默默地忍受……但痛苦就在那里,在不断处理有符号整数值的几天后,他无法停止诅咒,这应该是无符号的……甚至为这些场景很痛苦,我个人认为如果 java 省略有符号整数并提供无符号整数会更好......至少那时,你不必关心符号扩展等......你仍然可以将数字解释为 2s 补码。
这些是我在这件事上的 5 美分。
-ftrapv
这样的选项,可以捕获所有有符号溢出,但不能捕获所有无符号溢出。性能影响还不错,因此在某些情况下使用-ftrapv
进行编译可能是合理的。That's about the age of the universe measured in nanoseconds.
我对此表示怀疑。宇宙大约是13.7*10^9 years
旧,即4.32*10^17 s
或4.32*10^26 ns
。要将4.32*10^26
表示为 int,您至少需要90 bits
。9,223,372,036,854,775,807 ns
仅约为292.5 years
。