83

Google C++ Style Guide中,关于“无符号整数”的主题,建议

由于历史意外,C++ 标准也使用无符号整数来表示容器的大小——标准组织的许多成员认为这是一个错误,但实际上目前无法修复。无符号算术不模拟简单整数的行为,而是由标准定义以模拟模算术(围绕上溢/下溢)这一事实,这意味着编译器无法诊断出一类重要的错误。

模运算有什么问题?这不是无符号整数的预期行为吗?

该指南提到了什么样的错误(一个重要的类)?溢出错误?

不要仅仅使用无符号类型来断言变量是非负的。

我可以考虑使用有符号整数而不是无符号整数的一个原因是,如果它确实溢出(为负数),则更容易检测到。

4

7 回答 7

73

这里的一些答案提到了有符号和无符号值之间令人惊讶的提升规则,但这似乎更像是一个与混合有符号和无符号值有关的问题,并且不一定解释为什么在混合场景之外有符号变量比无符号变量更受欢迎。

根据我的经验,除了混合比较和提升规则之外,无符号值是错误磁铁的主要原因有两个,如下所示。

无符号值在零处不连续,这是编程中最常见的值

无符号和有符号整数在它们的最小值和最大值处都有不连续性,它们会环绕(无符号)或导致未定义的行为(有符号)。因为unsigned这些点都在UINT_MAX。因为int它们在INT_MININT_MAXINT_MININT_MAX在具有 4 字节值的系统上的典型int值是-2^312^31-1,在这样的系统UINT_MAX上通常是2^32-1

主要的导致错误的问题unsigned不适用于int在零处的不连续性。当然,零是程序中非常常见的值,还有其他小的值,如 1、2、3。在各种构造中添加和减去小值是很常见的,尤其是 1,如果你从一个unsigned值中减去任何东西并且它恰好为零,你就会得到一个巨大的正值和一个几乎肯定的错误。

考虑代码按索引遍历向量中的所有值,除了最后一个0.5

for (size_t i = 0; i < v.size() - 1; i++) { // do something }

这工作正常,直到有一天你传入一个空向量。不是进行零次迭代,而是得到v.size() - 1 == a giant number1,然后进行 40 亿次迭代,并且几乎存在缓冲区溢出漏洞。

你需要这样写:

for (size_t i = 0; i + 1 < v.size(); i++) { // do something }

所以在这种情况下可以“修复”它,但只能通过仔细考虑size_t. 有时您无法应用上述修复,因为您想要应用一些可变偏移量而不是常量偏移量,这可能是正数或负数:因此您需要将其放在比较的哪个“方面”取决于签名- 现在代码变得非常混乱。

尝试迭代到零(包括零)的代码也存在类似的问题。类似的东西while (index-- > 0)可以正常工作,但显然等价的东西while (--index >= 0)永远不会因无符号值而终止。你的编译器可能会在右侧字面为零时警告你,但如果它是在运行时确定的值,肯定不会。

对位

有些人可能会争辩说有符号值也有两个不连续性,那么为什么要选择无符号值呢?不同之处在于两个不连续性都非常(最大)远离零。我真的认为这是一个单独的“溢出”问题,有符号和无符号值都可能在非常大的值处溢出。在许多情况下,由于值的可能范围受到限制,溢出是不可能的,并且许多 64 位值的溢出在物理上可能是不可能的)。即使可能,与“零”错误相比,与溢出相关的错误的可能性通常很小,并且无符号值也会发生溢出。所以 unsigned 结合了两全其美:可能会溢出非常大的幅度值,以及零处的不连续性。签名的只有前者。

许多人会与未签名争论“你失去了一点”。这通常是正确的 - 但并非总是如此(如果您需要表示无符号值之间的差异,无论如何您都会丢失该位:这么多 32 位的东西无论如何都限制为 2 GiB,或者您会有一个奇怪的灰色区域说一个文件可以是 4 GiB,但你不能在第二个 2 GiB 的一半上使用某些 API)。

即使在未签名的情况下对你有一点好处:它对你没有多大好处:如果你必须支持超过 20 亿个“东西”,你可能很快就会不得不支持超过 40 亿个。

从逻辑上讲,无符号值是有符号值的子集

在数学上,无符号值(非负整数)是有符号整数的子集(仅称为 _integers)。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 - 超过 9 个quintillion。那是很多纳秒(大约值 292 年)和很多钱。它也是一个比任何计算机都更大的阵列,很可能在一个连贯的地址空间中长时间拥有 RAM。所以也许 9 quintillion 对每个人来说都足够了(现在)?

何时使用无符号值

请注意,样式指南并不禁止甚至不一定阻止使用无符号数字。它的结尾是:

不要仅仅使用无符号类型来断言变量是非负的。

事实上,无符号变量有很好的用途:

  • 当您不想将 N 位数量视为整数,而只是将其视为“位袋”时。例如,作为位掩码或位图,或 N 个布尔值或其他。这种用法通常与固定宽度类型(例如uint32_t,并且uint64_t因为您经常想知道变量的确切大小)密切相关。一个特定变量值得这种处理的提示是,您只能使用按位运算符(如~, |, &,^>>)对其进行操作,而不使用算术运算(如+, -,*等)对其进行操作/

    无符号在这里是理想的,因为按位运算符的行为是明确定义和标准化的。有符号值有几个问题,例如移位时未定义和未指定的行为,以及未指定的表示。

  • 当你真正想要模运算时。有时你实际上想要 2^N 模运算。在这些情况下,“溢出”是一个特性,而不是一个错误。无符号值在这里为您提供您想要的东西,因为它们被定义为使用模运算。有符号值根本不能(容易、有效地)使用,因为它们具有未指定的表示形式并且溢出是未定义的。


0.5写完这篇文章后,我意识到这与Jarod 的示例几乎相同,这是我从未见过的——而且有充分的理由,这是一个很好的示例!

1我们在这里谈论的size_t通常是 32 位系统上的 2^32-1 或 64 位系统上的 2^64-1。

2 在 C++ 中,情况并非完全如此,因为无符号值在上端包含的值比相应的有符号类型多,但存在操作无符号值可能导致(逻辑上)有符号值的基本问题,但没有相应的问题有符号值(因为有符号值已经包含无符号值)。

于 2018-08-04T04:37:05.703 回答
37

如前所述,混合unsignedsigned可能导致意外行为(即使定义明确)。

假设您要遍历 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对于更预期的 值范围也是如此。然后UB很快发生(一次越界访问)s.size() - 5i < v.size() - 5trueii >= v.size()

如果v.size()将返回有符号值,那么s.size() - 5将是负数,在上述情况下,条件将立即为假。

另一方面,索引应该介于两者之间,[0; v.size()[这样unsigned才有意义。Signed 也有其自身的问题,即 UB 具有溢出或实现定义的负符号数右移行为,但迭代的错误来源较少。

于 2018-08-03T18:36:20.477 回答
20

最令人毛骨悚然的错误示例之一是当您混合有符号和无符号值时:

#include <iostream>
int main()  {
    auto qualifier = -1 < 1u ? "makes" : "does not make";
    std::cout << "The world " << qualifier << " sense" << std::endl;
}

输出:

世界没有意义

除非你有一个简单的应用程序,否则你不可避免地会在有符号和无符号值之间出现危险的混合(导致运行时错误),或者如果你发出警告并使其成为编译时错误,你最终会得到很多代码中的 static_casts。这就是为什么最好严格使用有符号整数作为数学或逻辑比较的类型。仅对位掩码和表示位的类型使用无符号。

根据数字值的预期域将类型建模为无符号是一个坏主意。大多数数字更接近 0 而不是 20 亿,因此对于无符号类型,您的许多值更接近有效范围的边缘。更糟糕的是,最终值可能在已知的正范围内,但是在评估表达式时,中间值可能下溢,如果它们以中间形式使用,则可能是非常错误的值。最后,即使您的值预计始终为正,但这并不意味着它们不会与其他可能为负的变量交互,因此您最终会遇到强制混合有符号和无符号类型的情况,即最糟糕的地方。

于 2018-08-03T18:45:00.693 回答
12

为什么使用无符号整数比使用有符号整数更容易导致错误?

使用无符号类型不会比使用有符号类型来处理某些任务类更容易导致错误。

为工作使用正确的工具。

模运算有什么问题?这不是无符号整数的预期行为吗?
为什么使用无符号整数比使用有符号整数更容易导致错误?

如果任务匹配的话:没有错。不,可能性不大。

安全、加密和身份验证算法依赖于无符号模块化数学。

压缩/解压缩算法以及各种图形格式也受益,并且对无符号数学的错误更少。

任何时候使用按位运算符和移位,无符号运算都不会与有符号数学的符号扩展问题混淆


有符号整数数学具有直观的外观和感觉,包括编码学习者在内的所有人都很容易理解。C/C++ 最初不是目标语言,现在也不应该是一种介绍性语言。对于使用有关溢出的安全网的快速编码,其他语言更适合。对于精益快速代码,C 假设编码人员知道他们在做什么(他们是有经验的)。

今天有符号数学的一个缺陷是无处不在的 32 位int,它有很多问题,对于没有范围检查的常见任务来说足够宽。这导致了溢出没有被编码的​​自满情绪。相反,for (int i=0; i < n; i++) int len = strlen(s);被视为 OK,因为n假设 <INT_MAX并且字符串永远不会太长,而不是在第一种情况下或使用size_tunsigned甚至long long在第二种情况下受到全面保护。

C/C++ 开发的时代包括 16 位和 32 位int,无符号 16 位size_t提供的额外位意义重大。需要注意溢出问题,无论是它int还是unsigned.

使用 Google 在非 16 位平台上的 32 位(或更广泛)应用程序,鉴于其充足的范围int/unsigned,缺乏对 +/- 溢出的关注。int这对此类应用程序的鼓励是有道理intunsigned。然而,int数学并没有得到很好的保护。

狭窄的 16 位int/unsigned关注点在今天适用于选定的嵌入式应用程序。

Google 的指导方针非常适用于他们今天编写的代码。对于更大范围的 C/C++ 代码,它不是一个明确的指导方针。


我可以考虑使用有符号整数而不是无符号整数的一个原因是,如果它确实溢出(为负数),则更容易检测到。

在 C/C++ 中,有符号 int 数学溢出是未定义的行为,因此肯定比未定义的无符号数学行为更容易检测。


正如@Chris Uzdavinis所评论的那样,所有人(尤其是初学者)最好避免混合有符号无符号,并在需要时仔细编码。

于 2018-08-04T03:53:14.447 回答
5

我对谷歌的风格指南有一些经验,也就是很久很久以前进入公司的糟糕程序员的疯狂指令的搭便车指南。这个特定的指导方针只是那本书中数十条疯狂规则的一个例子。

如果您尝试对无符号类型进行算术运算(参见上面的 Chris Uzdavinis 示例),换句话说,如果您将它们用作数字,则只会在无符号类型上发生错误。无符号类型不打算用于存储数字量,它们旨在存储诸如容器大小之类的计数,它永远不能为负,它们可以而且应该用于该目的。

使用算术类型(如有符号整数)来存储容器大小的想法是愚蠢的。你也会使用双精度来存储列表的大小吗?Google 有人使用算术类型存储容器大小并要求其他人做同样的事情,这说明了该公司的一些情况。我注意到这样的命令的一件事是,他们越愚蠢,他们就越需要严格的“做它或你被解雇”的规则,否则有常识的人会忽略该规则。

于 2018-08-04T20:23:19.973 回答
1

使用无符号类型表示非负值...

  • 当使用有符号和无符号值时,更有可能导致涉及类型提升的错误,正如其他答案所展示和深入讨论的那样,但是
  • 不太可能导致涉及选择具有能够表示不受欢迎/不允许值的域的类型的错误。在某些地方,您会假设该值在域中,并且当其他值以某种方式潜入时,可能会出现意外和潜在的危险行为。

谷歌编码指南强调第一种考虑。其他指南集,例如C++ Core Guidelines,更加强调第二点。例如,考虑核心指南I.12

I.12:将一个不能为空的指针声明为not_null

原因

帮助避免取消引用 nullptr 错误。通过避免对nullptr.

例子

int length(const char* p);            // it is not clear whether length(nullptr) is valid
length(nullptr);                      // OK?
int length(not_null<const char*> p);  // better: we can assume that p cannot be nullptr
int length(const char* p);            // we must assume that p can be nullptr

通过在源代码中说明意图,实现者和工具可以提供更好的诊断,例如通过静态分析发现某些类别的错误,并执行优化,例如删除分支和空测试。

当然,您可以主张使用non_negative整数包装器来避免这两类错误,但这会有其自身的问题......

于 2018-08-04T23:06:30.170 回答
0

google 声明是关于使用 unsigned 作为容器的大小类型。相比之下,这个问题似乎更笼统。在您继续阅读时,请记住这一点。

由于到目前为止大多数答案都是对 google 声明的反应,而对更大的问题则更少,我将开始我的关于负容器大小的答案,然后尝试说服任何人(绝望,我知道......)unsigned 是好的。

签名的容器大小

让我们假设有人编写了一个错误,导致容器索引为负。结果是未定义的行为或异常/访问冲突。当索引类型未签名时,这真的比获得未定义的行为或异常/访问冲突更好吗?我想不是。

现在,有一类人喜欢谈论数学以及在这种情况下什么是“自然”的。具有负数的整数类型如何自然地描述本质上> = 0的事物?大量使用负大小的数组?恕我直言,特别是数学倾向的人会发现这种语义不匹配(大小/索引类型表示负数是可能的,而负数数组很难想象)令人讨厌。

因此,在这个问题上剩下的唯一问题是——如谷歌评论中所述——编译器实际上是否可以积极协助发现此类错误。甚至比替代方案更好,这将是下溢保护的无符号整数(x86-64 程序集和可能其他架构有实现这一点的方法,只有 C/C++ 不使用这些方法)。if (index < 0) throwOrWhatever我能理解的唯一方法是编译器是否自动添加了运行时检查(我有疑问,这会有所帮助。

此外,实际上为他们的数组/容器索引编写运行时检查的人,处理有符号整数的工作更多。而不是写if (index < container.size()) { ... }你现在必须写:if (index >= 0 && index < container.size()) { ... }。在我看来像是强迫劳动,而不是改善...

没有无符号类型的语言很糟糕......

是的,这是对 java 的一次尝试。现在,我来自嵌入式编程背景,我们在现场总线上工作了很多,其中二进制操作(和,或,异或,...)和值的按位组合实际上是面包和黄油。对于我们的一种产品,我们——或者更确切地说是客户——想要一个 Java 端口……而我坐在对面,幸运的是,那个非常能干的人做了这个端口(我拒绝了……)。他试图保持镇定......并默默忍受......但痛苦就在那里,在不断处理有符号整数值的几天后,他无法停止诅咒,这应该是无符号的......甚至为这些场景很痛苦,我个人认为,如果 java 省略了有符号整数而只提供无符号整数,那么 Java 会更好......至少那时,你不必关心符号扩展等......

这些是我在这件事上的 5 美分。

于 2020-09-17T22:13:18.703 回答