可变参数列表和空指针

10

考虑以下代码:

#include <stdarg.h>
#include <stdlib.h>
#include <stdio.h>

void foo(const char *arg, ...) {
    va_list args_list;

    va_start(args_list, arg);

    for (const char *str = arg;
         str != NULL;
         str = va_arg(args_list, const char *)) {
        printf("%s\n", str);
    }

    va_end(args_list);
}

int main(int argc, char **argv) {
    foo("Some", "arguments", "for", "foo", NULL);
    foo("Some", "arguments", "for", "foo", 0);
    return 0;
}

正如我们所看到的,foo() 使用可变参数列表获取字符串列表并将它们全部打印出来。假设最后一个参数是空指针,因此直到检测到 NULL 时才处理参数列表。 main() 以两种不同的方式调用函数 foo(),分别使用 NULL0 作为最后一个参数。
我的问题是:第二次调用使用 0 作为最后一个参数是否正确?
我认为,我们不应该使用 0 调用 foo()。原因是,在这种情况下,例如,编译器无法从上下文中猜测出 0 应该被视为空指针。因此,它将其处理为普通整数。然后,foo() 处理 0 并将其转换为 const char*。当空指针具有与 0 不同的内部表示时,就会出现魔法。据我所知,这会导致检查 str != NULL 失败(因为在我们的情况下,str 将等于转换为 const char*0,它与空指针不同),从而导致程序行为错误。
我的想法正确吗?任何好的解释都会受到赞赏。
2个回答

9
一般情况下,这两个调用都是不正确的。
使用裸的0作为参数传递到foo()函数中是错误的,但原因并非您所述。对于具有可变参数的函数foo()的调用编译时,编译器无法知道foo()期望的类型。如果将0强制转换为const char *,那么这是可以接受的;即使空指针具有与所有位均为零不同的内部表示,语言也保证在指针上下文中使用值0会导致空指针。如果需要,(这可能需要编译器实际生成一些非平凡的代码来进行类型转换,但如果是这样,它必须这样做。)
但是编译器没有理由认为0是一个指针。相反,它将把0作为int传递。如果int与指针的大小不同,或者如果任何其他原因导致int 0具有不同于空指针的表示形式,或者如果此系统以不同于整数的方式传递指针参数,则可能会出现问题。
因此,这种情况是未定义的行为:foo使用va_arg获取作为int类型传递而实际上被声明为const char *类型的参数。
那么使用NULL呢?根据此答案及其中的引用,C标准允许将宏NULL定义为简单的0或任何其他“值为0的整数常量表达式”。与流行观点相反,它不一定是(void *)0,尽管可能是。
因此,传递裸的NULL是不安全的,因为您可能在定义中将其定义为0的平台上。然后您的代码可能会出现与上述相同的错误原因而失败。
要安全和可移植,请编写以下任意一个:
 foo("Some", "arguments", "to", "foo", (const char *)0);

或者

 foo("Some", "arguments", "to", "foo", (const char *)NULL);

但是你不能省略掉这个 cast。

你说得对。只需要注意附加的平台标准可能需要 #define NULL ((void *)0),因此这是非常安全的。另外,将其转换为 void * 是足够的。 - too honest for this site
2
@NateEldredge 我刚刚检查了一下,POSIX要求将NULL定义为整数常量0转换为void*,因此在所有类POSIX系统上第一个调用是正确的。 - fuz
2
第二部分是正确的,但第一部分可能更糟。想象一个使用32位int和64位指针在堆栈上传递参数的平台,其中空指针是所有位都为0。当您只传递0时,编译器将32位0推送到堆栈上。但是当您使用const char *调用va_arg时,编译器从该地址获取64位。其他32位可能是任何旧垃圾,它们恰好占据了接下来的几个字节... - Nate Eldredge
2
所以 foo() 将得到一个非空指针,并将其视为非哨兵。它将尝试将其视为字符串指针。但是由于它包含垃圾数据,程序可能会崩溃(或执行其他不良操作)。我依稀记得曾经追踪过一个错误,其中某人使用最后一个参数0调用了execlp(),这正是发生的事情。 - Nate Eldredge
2
因此,您甚至不能保证得到一个填充了零的const char *。在这种情况下,整数0从未被强制转换为指针(这将意味着编译器有机会将0转换为适当的空指针)。您只是从存储int的位置获取指针,而这样做可能会出现许多问题。 - Nate Eldredge
显示剩余8条评论

8
第二次调用不正确,因为您传递的参数类型为 int,而您使用 va_arg 获取的参数类型为 const char*。这是未定义的行为。
只有当 NULL 被声明为 (void*)0 或类似形式时,第一次调用才是正确的。请注意,根据标准,NULL 只需要是一个空指针常量即可。它不必被定义为 ((void*)0),但通常情况下是这样的。某些系统将 NULL 定义为 0,在这种情况下,第一次调用是未定义的行为。POSIX 规定:“该宏应扩展为一个整数常量表达式,其值为 0 强制转换为类型 void *”,因此在类似 POSIX 的系统上,您可以安全地假设 NULL((void*)0)
以下是 ISO 9899:2011 §6.5.2.2 中相关的标准引用:

6.5.2.2 函数调用

(...)

6 如果表示所调用函数的表达式具有不包括原型的类型,则对每个参数执行整数提升,并将类型为 float 的参数提升为 double。这些被称为 默认参数提升。如果参数数量不等于参数数量,则行为未定义。如果函数定义了包含省略号 (, ...) 的原型,或者在提升后的参数类型与参数类型不兼容,则行为未定义。 如果函数定义了不包括原型的类型,并且在提升后的参数类型与提升后的参数类型不兼容,则行为未定义,但以下情况除外:

  • 一个提升类型是有符号整数类型,另一个提升类型是相应的无符号整数类型,并且该值在两种类型中都可表示;
  • 两种类型都是字符类型或 void 的限定或未限定版本的指针。

7 如果表示所调用函数的表达式具有包括原型的类型,则参数会被隐式转换,就像通过赋值一样,转换成相应参数的类型,将每个参数的类型视为其声明类型的未限定版本。函数原型声明符中的省略号符号使得参数类型转换在最后一个声明的参数之后停止。默认参数提升在尾部参数上执行。

8 没有执行其他隐式转换;特别是,不会将参数的数量和类型与不包括函数原型声明符的函数定义中的参数进行比较。

¶8澄清了当传递给...参数时,整数常量0不会被转换为指针类型。


你能用简单的话解释一下空指针常量的概念吗?我读过关于空指针概念、空指针和空指针常量的内容,但现在在这个领域有些卡住了;( - Edgar Rokjān
1
@EdgarRokyan 空指针常量是一个整数常量表达式,其值为零或将此表达式强制转换为类型 void*。当转换为任何指针类型时,它会产生一个空指针。空指针是一个指针,保证不等于任何指向函数或对象的指针。 - fuz
1
@FUZxxl:当未转换为指针类型时,它是定义的任何类型,可能是int。常量0空指针常量,但它不是指针类型。 - Keith Thompson
感谢您的详细解释! - Edgar Rokjān
@EdgarRokyan 很高兴为您服务。 - fuz

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