在C语言中,即使数字是非负数,您是否应始终使用'int'?

54

我总是使用无符号整型(unsigned int)来表示永远不会为负数的值。但今天我在我的代码中发现了这种情况:

void CreateRequestHeader( unsigned bitsAvailable, unsigned mandatoryDataSize, 
    unsigned optionalDataSize )
{
    If ( bitsAvailable – mandatoryDataSize >= optionalDataSize ) {
        // Optional data fits, so add it to the header.
    }

    // BUG! The above includes the optional part even if
    // mandatoryDataSize > bitsAvailable.
}

即使数字不可能为负数,我是否应该开始使用int,而不是unsigned int


14
如果(bitsAvailable >= optionalDataSize + mandatoryDataSize)时,有什么问题吗? - Russell Borogove
1
Java不支持无符号类型,因此如果您计划与Java进行代码交互,除非您实际需要特定值的类型范围,否则应避免使用这些类型。我认为仅出于指示不支持/不允许负值的目的而使用无符号是不合适的。 - David
6
另外提醒一下:这种类型的漏洞是良好的静态代码分析工具可以帮你找出来的。Coverity 就能发现类似于这个问题的错误,虽然我没有足够的使用其他工具的经验来做出判断,但我相信大部分工具都可以捕捉到这种问题。这里有一个可用工具列表:http://en.wikipedia.org/wiki/List_of_tools_for_static_code_analysis - Mattias Nilsson
参见:https://dev59.com/QHI-5IYBdhLWcg3wPF12,适用于C++,但大部分答案仍然适用。 - BlueRaja - Danny Pflughoeft
3
@Russell,这也不是完美的解决方案。加法可能导致溢出并包装成无符号数。 - Nyan
16个回答

132

还有一件事情没有被提到,那就是交换有符号/无符号数可能会导致安全漏洞。这是一个很大的问题,因为标准 C 库中的许多函数都需要/返回无符号数字(如 fread、memcpy、malloc 等函数都需要 size_t 参数)。

例如,看下面这个看似无害的代码示例(来自真实代码):

//Copy a user-defined structure into a buffer and process it
char* processNext(char* data, short length)
{
    char buffer[512];
    if (length <= 512) {
        memcpy(buffer, data, length);
        process(buffer);
        return data + length;
    } else {
        return -1;
    }
}

看起来无害,对吧? 问题在于length是有符号的,但在传递给memcpy时会转换为无符号。 因此,将长度设置为SHRT_MIN将验证<= 512测试,但会导致memcpy将超过512字节的内容复制到缓冲区 - 这允许攻击者覆盖堆栈上的函数返回地址,并最终接管计算机!

你可能天真地说:"很明显,长度需要是size_t或检查是否>= 0,我永远不会犯这种错误"。 不过,我保证如果你曾经写过任何非平凡的东西,你都会这样做。 WindowsLinuxBSDSolarisFirefoxOpenSSLSafariMS PaintInternet ExplorerGoogle PicasaOperaFlashOpen OfficeSubversionApachePythonPHPPidginGimp以此类推...... - 这些都是明智的人,他们的工作就是了解安全。

简而言之,始终使用size_t作为大小。

哎呀,编程真难


15
忘记边界检查会导致安全漏洞。如果你在另一个方向上弄错了,即使是“unsigned”也无法帮助你,你的函数仍会愉快地写入“myArray[0xFFFFFFFF]”。 - dan04
15
@dan04: 不,根本原因是在应该使用无符号整数(比如size_t)时使用了有符号整数(或者更确切地说,是有符号/无符号数之间的隐式转换)。当然,忘记检查边界也是一个问题。我已经修改了例子以使其更清晰明了 - 谢谢。 - BlueRaja - Danny Pflughoeft
1
@szx:https://dev59.com/questions/emYq5IYBdhLWcg3wxjc7 - BlueRaja - Danny Pflughoeft
9
我仍然不明白为什么省略对length的下界进行边界检查不是根本问题。当然,你可以使用无符号类型,比如size_t,但是这样你甚至不能检查下界是否为非负数。由于隐式转换规则,这只会导致不同的错误。这怎么算是一种改进呢? - Cody Gray
2
@dan04:不会写入。如果使用unsigned int length = 0xFFFFFFFF,那么if (length <= 512)将评估为false - Adam
显示剩余7条评论

30

我是否应该总是...

"我是否应该总是..."的答案几乎肯定是“不”,有很多因素决定你是否应该使用数据类型-一致性很重要。

但是,这是一个高度主观的问题,无符号数很容易出错:

for (unsigned int i = 10; i >= 0; i--);

导致无限循环。

这就是为什么一些风格指南,包括Google的C++风格指南不鼓励使用unsigned数据类型。

就我个人而言,我并没有遇到很多由于无符号数据类型造成的错误 —— 我会说使用断言来检查代码,并审慎地使用它们(在进行算术计算时使用它们较少)。


1
在我看来,unsigned 帮助在编译阶段而非运行时捕获错误。像数量这样的序数值应该使用 unsigned int 而不是 signed int - Thomas Matthews
12
未检测到的下溢和上溢是基本的C语言陷阱 - 使用有符号和无符号数据类型会改变错误情况,但并不能消除任何错误。当然,将错误情况放在零右侧可能是一件特别糟糕的事情,但正如你所说,这取决于你正在做什么。在上面的循环中,您可以检查!= ~0是否为您的结束条件 - 这是一种有用的无符号无效/结束值。这是一个小技巧(因为0是int型,所以~0-1),但在理智的机器上,隐式转换可以正常工作,并且在视觉上比使用无符号的-1更不奇怪。 - user180247
3
@Thomas:谢谢您的反馈,但我并不完全同意。C(和C++)提供了signedunsigned类型之间的隐式转换,这可能会产生静默且令人惊讶的结果。除非您传递其他编译器警告标志,否则两者之间没有太多语法约束可以触发编译失败。 unsigned 类型的好处主要是语义上的,除非您特别使用无符号类型来避免对符号位进行操作(例如在位掩码中)。 - Stephen
1
当你使用有符号数表示大小参数时,会发生糟糕的事情™。请查看我的帖子。 - BlueRaja - Danny Pflughoeft
我认为对于Google的样式指南的描述并不完全准确。这是它实际上所说的:"在C整数类型中,只应使用'int'。如果合适的话,您可以使用标准类型,如'size_t'和'ptrdiff_t'。"然后它继续详细说明了一些情况,在这些情况下,您应该使用'int'而不是无符号类型之一(如'uint32_t')。也许自回答撰写以来,Google的样式指南已经发生了变化? - D.W.
显示剩余3条评论

15

一些应该使用无符号整数类型的情况包括:

  • 您需要将数据作为纯二进制表示。
  • 您需要使用无符号数获得模算术的语义。
  • 您必须与使用无符号类型的代码进行接口交互(例如,接受/返回 size_t 值的标准库例程)。

但是对于一般的算术运算,有一点需要注意:当你说某个值“不能为负”,这并不一定意味着你应该使用无符号类型。因为您可以将负值放入无符号类型中,只是当您取出时它将变成一个非常大的值。所以,如果您的意思是禁止使用负值,比如基本平方根函数,那么您正在说明函数的前提条件,并且您应该使用断言。而不能断言不能存在的内容,您需要一种方式来保存带外值,以便进行测试(这与 getchar() 返回 int 而不是 char 的逻辑相同)。

此外,有符号和无符号的选择也可能对性能产生影响。请看下面(人为制造的)代码:

#include <stdbool.h>

bool foo_i(int a) {
    return (a + 69) > a;
}

bool foo_u(unsigned int a)
{
    return (a + 69u) > a;
}

除了参数类型不同外,两个foo函数是相同的。但是,当使用c99 -fomit-frame-pointer -O2 -S编译时,您会得到:

        .file   "try.c"
        .text
        .p2align 4,,15
.globl foo_i
        .type   foo_i, @function
foo_i:
        movl    $1, %eax
        ret
        .size   foo_i, .-foo_i
        .p2align 4,,15
.globl foo_u
        .type   foo_u, @function
foo_u:
        movl    4(%esp), %eax
        leal    69(%eax), %edx
        cmpl    %eax, %edx
        seta    %al
        ret
        .size   foo_u, .-foo_u
        .ident  "GCC: (Debian 4.4.4-7) 4.4.4"
        .section        .note.GNU-stack,"",@progbits

你可以看到foo_i()foo_u()更有效率。这是因为无符号算术溢出被标准定义为“环绕”,所以如果a非常大,(a + 69u)可能会比a小,因此必须有代码处理这种情况。另一方面,有符号算术溢出是未定义的,因此GCC将假定有符号算术不会发生溢出,因此(a + 69)永远不会小于a。因此,盲目选择无符号类型可能会不必要地影响性能。


12
答案是肯定的。C和C++中的 "unsigned" int 类型并不是“始终为正整数”,无论类型名称看起来像什么。如果您试图将C/C++无符号整数类型解释为“非负数”,它的行为就没有意义...例如:
  • 两个无符号数之差也是一个无符号数(如果你将其读作“两个非负数之差是非负数”就没有意义)
  • int 和 unsigned int 相加后结果为无符号数
  • 从 int 到 unsigned int 有隐式转换(如果你将unsigned读作“非负”那么相反的转换才有意义)
  • 如果您声明一个接受无符号参数的函数,当有人传递一个负 int 值时,它会被隐式转换为一个巨大的正值;换句话说,使用无符号参数类型不能帮助您在编译时或运行时找到错误。

实际上,无符号数字在某些情况下非常有用,因为它们是“整数取模N”的环的元素,其中N是2的幂次方。当您想要使用该取模算术或作为位掩码时,无符号整数很有用;但它们作为数量却没有用。

不幸的是,在C和C++中,无符号数也用于表示非负数量,以便在整数很小时使用所有16位...当时能够使用32k或64k被认为是一个很大的区别。我会将其归类为历史偶然事件...你不应该试图读取其逻辑,因为没有逻辑。

顺便说一句,在我看来,那是一个错误...如果32k不够用,那么很快64k也不够用;仅仅因为多了一个比特就滥用模数整数,在我看来代价太高了。当然,如果存在或定义了一个合适的非负类型,这样做是合理的...但是,将 unsigned 语义用作非负就是错误的。

有时你会听到一些人说无符号类型很好,因为它“记录”了你只想要非负值... 然而,这种文档只对那些实际上不知道C或C++中无符号类型工作原理的人有价值。对我来说,看到无符号类型用于非负值只意味着编写代码的人在那个部分不理解语言。
如果你真的理解并且想要无符号整数的“包装”行为,那么它们是正确的选择(例如,当我处理字节时,我几乎总是使用“unsigned char”); 如果您不打算使用包装行为(而该行为将成为您的问题,如您所示的区别),则这清楚地表明无符号类型是一个不好的选择,您应该坚持使用普通整数。
这是否意味着C++的std::vector<>::size()返回类型是一个糟糕的选择?是的... 这是一个错误。但是,如果您这样说,请准备好被那些不理解“unsigned”只是一种名称的人谩骂... 它重要的是行为,而这是一种“模-n”行为(没有人会认为使用“模-n”类型来表示容器大小是一个明智的选择)。

5
-1。嗯,我是说+4294967295 :) “unsigned”的语义是不合逻辑的。 - dan04
2
@dan04:无符号整数的问题在于它们被用于两个不同的目的,每个目的都可能有一个合理的规则集,但是C语言从这两个目的中混杂了一些规则。对于一些事情来说,包装数字类型非常有用。例如,在处理TCP数据包时,能够使用 tcp->stuffed - tcp->acked 并知道已经塞入缓冲区但未被确认的字节数非常有用,即使序列号已经包装了。问题在于,无符号值没有一致的包装语义... - supercat
因为它们通常用于保存永远不会为负数的值,但是这些值太大而无法适应相同大小的无符号类型。无符号类型的包装行为并不是被设计出来的,而是在早期系统中自然发生并且非常有用。 - supercat
在许多具有16位“int”类型的系统上,拥有比32K大的单个对象是很常见的,但有效地处理大于64K的对象需要一个更大的“int”类型。 “unsigned int”的问题在于,正如您所正确指出的那样,它用于服务于两个不同的角色(数字与代数环)。我希望C能够添加新的独立类型,用于表示自然数,最高可达2^2^n-1 [例如65535],自然数最高可达2^(2^n-1)-1 [例如32767],以及模2^2^n的代数环 [例如65536],每种情况下都有更好的语义。 - supercat

11

《C++程序设计语言》的创始人Bjarne Stroustrup在他的书中警告不要滥用无符号类型:

无符号整数类型非常适合将存储视为位数组的用途。为了多表示一个正整数而使用无符号类型代替int几乎从来不是一个好主意。通过声明变量为无符号类型以确保某些值为正数的尝试通常会被隐式转换规则所打败。


然而,标准库在容器大小上使用了无符号类型(这是C++程序中错误的主要来源)... - 6502
@6502 我会使用迭代器与标准容器进行接口交互,几乎每个任务都是如此,除了最琐碎或一次性的代码片段。 - Khaled Alshaya
更明确地说:他并没有警告关于通常情况!他只是警告不要试图通过使用无符号而不是有符号来扩展值范围! - Aconcagua

7

我似乎和大多数人意见不同,但我发现 unsigned 类型非常有用,但不是它们的 原始的 形式。

如果您一直坚持类型表示的语义,那么就不会有问题:对于数组索引、数据偏移等使用 size_t (无符号),对于文件偏移使用 off_t (有符号)。对于指针之间的差异使用 ptrdiff_t (有符号)。对于小的无符号整数使用 uint8_t ,对于有符号的整数使用 int8_t。这样你就可以避免至少80% 的可移植性问题。

如果不必要,请勿使用 intlongunsignedchar。它们属于历史书籍。(有时候你必须使用,比如错误返回,位域等)

回到你的例子:

bitsAvailable – mandatoryDataSize >= optionalDataSize

可以很容易地重写为

bitsAvailable >= optionalDataSize + mandatoryDataSize

这不会避免潜在的溢出问题(assert 是你的朋友),但我认为它让你更接近你想要测试的思路。


1
您IP地址为143.198.54.68,由于运营成本限制,当前对于免费用户的使用频率限制为每个IP每72小时10次对话,如需解除限制,请点击左下角设置图标按钮(手机用户先点击左上角菜单按钮)。 - supercat
@JensGustedt:直到最近,大多数嵌入式系统使用16位的' int '。如果在这样的系统上编写代码,以在写入流重复值后更新16位二进制补码或一进制补码校验和,则将两个'uint16_t'相乘是自然的方法。此外,直到最近,99.9%的32位系统的C编译器都可以毫不费力地产生完全相同的计算结果。虽然有人认为表达式最好写成'1uxx'的形式,但我认为需要后者形式的原因是语言规范的缺陷。 - supercat
@JensGustedt:此外,我认为size_tptrdiff_t并没有太大的帮助;考虑这个例子:char foo[100],*p1 = foo,*p2 = foo + 100;那么(p1-p2) > sizeof foo的值是多少?在一些最大项大小在32768到65535之间或在2147483648到4294967295之间的系统上,表达式将产生0,但在许多其他系统上,它将产生1。 - supercat
你是指 p2 - p1 吗?只有当两个指针都指向同一个对象内部(或者一个超出对象范围的位置)时,p2 - p1 才有定义。因此,根据定义,该值适合于 size_t。如果你真的是想表达 p1-p2,那么结果类型是 ptrdiff_t,因此这是一个负值。如果发生下溢,则行为未定义。我仍然不清楚你想证明或证伪什么。到目前为止,你唯一展示的就是对我回答中所说内容的某些不适感。 - Jens Gustedt
1
@JensGustedt: 顺便说一句,我刚刚浏览了你关于C语言缺陷的博客。我认为C语言最大的问题是缺乏一个标准的方法,让程序能够告诉编译器:“这是我对实现的要求;你应该给我满足我的要求或者拒绝编译”。目前,许多编译器提供命令行开关来控制char是有符号还是无符号,整数溢出等行为是否完全可预测,或者部分可预测,甚至违背时间和因果定律,等等。但是,目前没有标准的方式让程序指定要求。 - supercat
显示剩余7条评论

6
if (bitsAvailable >= optionalDataSize + mandatoryDataSize) {
    // Optional data fits, so add it to the header.
}

只要强制数据大小(mandatoryDataSize)和可选数据大小(optionalDataSize)不会超出无符号整数类型的范围,就可以保证没有漏洞。这些变量的命名让我相信这很可能是真的。


6

在可移植的代码中,你无法完全避免使用无符号类型,因为标准库中的许多typedef都是无符号的(最显著的是size_t),并且许多函数返回这些typedef(例如std::vector<>::size())。

尽管如此,出于你所提到的原因,我通常更喜欢尽可能使用有符号类型。这不仅仅是你提出的情况 - 在混合有符号/无符号算术的情况下,有符号参数会被静默地提升为无符号。


3

以下是Eric Lippert博客文章的评论之一的内容(请参见此处):

Jeffrey L. Whitledge

我曾经开发过一个系统,其中负值作为参数没有意义,所以我认为不验证参数值是否为非负数,而是使用uint会是个好主意。但很快我就发现,每当我将这些值用于任何事情(比如调用BCL方法)时,它们都必须被转换为有符号整数。这意味着我必须验证这些值在顶端没有超过有符号整数范围,因此我没有获得任何好处。而且,每次调用代码时,正在使用的整数(通常是从BCL函数接收到的)都必须转换为uints。不久之后,我将所有这些uints都改回了ints,并去掉了所有不必要的强制类型转换。我仍然需要验证数字是否为负数,但代码更加清晰!

Eric Lippert

我自己几乎无法说得更好了。你几乎永远不需要uint的范围,而且它们不符合CLS标准。表示小整数的标准方法是使用“int”,即使其中有超出范围的值。一个好的经验法则:仅在与期望uints的非托管代码进行交互或明显将整数用作位集而不是数字的情况下使用“uint”。始终尝试在公共接口中避免使用它。

  • Eric

这是关于C#的,不是C。 - BlueRaja - Danny Pflughoeft
@BlueRaja:具体的例子是针对C#的,但评论所提到的一般观点仍然非常正确。 - Brian
正如我在帖子中提到的那样,对于需要大小参数的API,您应该使用无符号数据类型(使用size_t)。但在.Net中并非如此,缓冲区溢出不是问题。 - BlueRaja - Danny Pflughoeft
@BlueRaja:引用明确指出,当调用期望无符号整数的代码时,应使用无符号数据类型。 - Brian
1
我是说,无论你在调用什么,对于需要大小参数(在C中)的自己的API,你应该使用无符号数据类型。 - BlueRaja - Danny Pflughoeft
@BlueRaja:我不明白为什么这不是从引用中自然得出的结论。虽然你应该尽量避免在公共接口中使用它,但如果你的公共接口所交互的代码期望一个无符号数据类型,那么你将需要使用一个无符号值来与你通过该接口调用的代码进行交互。 - Brian

2
当类型为无符号的且 bitsAvailable < mandatoryDataSize 时,(bitsAvailable – mandatoryDataSize) 的结果可能会出现“意外”情况,这是有时即使数据不应该为负数也使用有符号类型的原因之一。
我认为没有硬性规定-通常我会默认使用无符号类型来存储不应该为负数的数据,但是你必须确保算术环绕不会导致错误。
另一方面,如果你使用有符号类型,仍然需要考虑溢出的情况:
MAX_INT + 1

重点是在处理这类错误时,您必须小心进行算术运算。

“包装”是无符号整数唯一有趣的特征(对于常规整数,您只有未定义的行为)。如果包装将成为问题(或者如果您必须小心避免它),那么这明确表明“unsigned”是错误的选择。 使用无符号并且在包装方面遇到问题(这是无符号类型最独特的特征)是无意义的...当您使用无符号时,您想要的就是包装...您应该选择无符号是因为其包装行为... - 6502
@6502:你说得很有道理,我认为有时候我会使用无符号类型,而有符号类型可能更好。但我认为也有例外;例如,在处理文件大小时,您可能需要能够处理完整的 size_t 范围(甚至一些更大的无符号类型),但仍然需要处理包装错误。 - Michael Burr

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接