这些函数类型在C中是否兼容?

32

考虑以下C程序:

int f() { return 9; }
int main() {
  int (*h1)(int);
  h1 = f; // why is this allowed?                                               
  return h1(7);
}
根据C11标准,第6.5.16.1节,在简单赋值中,“以下情况之一应成立”,并且列表中唯一相关的是以下情况:左操作数具有原子、限定或未限定的指针类型,并且(考虑到在lvalue转换后左操作数将具有的类型)两个操作数都是指向兼容类型的限定或未限定版本的指针,并且左边所指的类型具有右边所指类型的所有限定符;此外,这是一个“约束”,这意味着如果违反该约束,则符合要求的实现必须报告诊断消息。看起来,在上述程序的赋值中违反了这个约束。赋值的两边都是函数指针。所以问题是,这两个函数类型是否兼容? 这在6.7.6.3节中得到回答:为了使两个函数类型兼容,两者都必须指定兼容的返回类型。此外,参数类型列表(如果两者都存在)应在参数数量和省略号终止符的使用方面达成一致;相应参数应具有兼容类型。如果其中一个类型具有参数类型列表,而另一个类型由不是函数定义的函数声明符指定,并包含空标识符列表,则参数列表不得具有省略号终止符,并且每个参数的类型都必须与应用默认参数提升后的类型兼容。如果其中一个类型具有参数类型列表,而另一个类型由包含(可能为空的)标识符列表的函数定义指定,则两者应在参数数量上达成一致,并且每个原型参数的类型都必须与相应标识符的类型应用默认参数提升后的类型兼容。 在本例中,其中一个类型(h1的类型)具有参数类型列表;另一个类型(f)没有。因此,上面引用中的最后一句话适用:特别是,“两者的参数数量应一致”。显然,h1需要一个参数。那么f呢?在上述内容之前,发生了以下情况:函数声明符中的空列表是该函数定义的一部分,指定该函数没有参数。因此,很明显f不需要参数。因此,这两种类型的参数数量不一致,两种函数类型不兼容,赋值违反了约束,并且应发出诊断消息。但是,无论是gcc 4.8还是Clang在编译程序时都未发出警告。
tmp$ gcc-mp-4.8 -std=c11 -Wall tmp4.c 
tmp$ cc -std=c11 -Wall tmp4.c 
tmp$

顺便提一下,如果f被声明为"int f(void)...",那么这两个编译器都会发出警告,但根据我对标准的阅读,这应该是不必要的。

问题:

Q1:程序中的赋值"h1=f;"是否违反了约束条件"两个操作数都是限定或未限定版本的兼容类型的指针"?

具体地:

Q2: 表达式"h1=f"中h1的类型是某个函数类型T1的指针。T1到底是什么?

Q3: 表达式"h1=f"中f的类型是某个函数类型T2的指针。T2到底是什么?

Q4: T1和T2是兼容类型吗?(请引用标准或其他文件中的适当部分以支持答案。)

Q1',Q2',Q3',Q4':现在假设f的声明被改为"int f(void){return 9;}"。再次回答这个程序的问题1-4。


1
如果我将其放入clang中,会得到以下错误: functCheck.cxx:4:6: 错误:从不兼容的类型'int ()'分配给'int (*)(int)':参数数量不同(1 vs 0) h1 = f; // why is this allowed... ^ ~ 1个错误已生成。 - kwierman
6
@user2950041:这是一个C语言问题。C语言对声明、原型和定义的概念与C++不同。 - Kerrek SB
7
实际上编译器是会关心的。Clang会从文件扩展名中推断出语言类型。(例如,你可以在cxx文件中放置模板,并使用cc进行编译;但如果你将文件命名为foo.xyz,则无法编译) - nneonneo
8
在您引用的内容中我并未发现任何支持这个说法的内容,但是在“普通的C语言”中,空参数列表意味着您可以提供任意数量的参数。也就是说,void f() != void f(void)。我对C语言的深层次知识不够自信,无法确定f() == f(...),但我怀疑这并非如此,因为可变参数至少需要一个参数来进行钩子连接。 - Stian Svedenborg
1
@user2950041,我最终认为这是一个bug,你觉得在gcc Bugzilla上报告一个bug怎么样? - ouah
显示剩余11条评论
5个回答

8

这两份缺陷报告涉及您的问题:

缺陷报告316说(接下来是我的强调):

6.7.5.3#15中有关函数类型兼容性的规则未定义何时函数类型由包含(可能为空的)标识符列表的函数定义指定,[...]

它有一个类似于您提供的示例:

void f(a)int a;{}
void (*h)(int, int, int) = f;

接着说:

我认为标准的意图是,仅通过函数定义来指定类型,以检查同一函数的多个声明的兼容性;当函数名称出现在表达式中时,其类型由其返回类型确定,并不包含任何参数类型的痕迹。然而,实现解释有所不同。

问题2:上述翻译单元是否有效?

委员会的答复是:

委员会认为问题1和2的答案都是肯定的。

这是在C99和C11之间进行的,但委员会补充道:

我们没有意图修复旧的样式规则。然而,本文档中提出的观察结果似乎是普遍正确的。

就我所知,在你引用的问题部分中,C99和C11并没有太大的区别。如果我们进一步查看“缺陷报告317”,可以看到它说:

I believe the intent of C is that old-style function definitions with empty parentheses do not give the function a type including a prototype for the rest of the translation unit. For example:

void f(){} 
void g(){if(0)f(1);}

Question 1: Does such a function definition give the function a type including a prototype for the rest of the translation unit?

Question 2: Is the above translation unit valid?

委员会的回应是:

问题1的答案是否定的,问题2的答案是肯定的。没有任何约束违规,但是,如果执行函数调用,则会出现未定义的行为。请参见6.5.2.2;p6。

这似乎取决于函数定义是定义类型还是原型的事实不明确,因此意味着没有兼容性检查要求。这最初是旧式函数定义的意图,委员会不会进一步澄清,可能是因为它已被弃用。

委员会指出,仅因为翻译单元有效并不意味着没有未定义的行为。


在DR317中,它明确标注为1.否,2.是;标准清楚地指出void f() { }不构成原型。 - M.M
DR316 Q2与此SO帖子提出的问题相同;解决方案是委员会认为应该接受代码(因此可以推断引用自6.7.6.3的文本存在缺陷)。 - M.M
谢谢,这确实解决了问题,但我仍然对“意图”感到困惑。在赋值“h1=f”中,“h1”的类型是什么,而“f”的类型又是什么,为什么这两个指针类型的基本类型是兼容的?如果我们在“f”的定义中添加“void”,情况也是如此,此时所有人(包括编译器)都认为基本类型不兼容。 - Steve Siegel
@SteveSiegel 我认为正在发生的是gccclang在处理旧式K&R定义时存在差异。因此,您概述的问题似乎仅适用于那些形式。似乎void形式不被视为旧式。这可能是任意的,缺陷报告并不是非常详细,可能是有意为之。我猜只有gcc和llvm团队才能充分解释这一点。 - Shafik Yaghmour
即使阅读了缺陷报告,我仍然不理解赋值表达式中h1和f的类型是什么。我在主问题中添加了具体的问题Q1-Q4和Q1'-Q4',希望这个答案可以得到改进以回答那些具体的问题。 - Steve Siegel

3

历史上,C编译器通常以一种保证忽略额外参数的方式处理参数传递,并且只需要程序为实际使用的参数传递参数,从而允许例如:

int foo(a,b) int a,b;
{
  if (a)
    printf("%d",b);
  else
    printf("Unspecified");
}

为了能够安全地通过foo(1,123);foo(0);两种方式进行调用,而不必在后一种情况下指定第二个参数。即使在正常调用约定不支持此类保证的平台(例如经典 Macintosh)上,C 编译器通常也默认使用支持它的调用约定。

标准明确表示编译器不需要支持这样的用法,但要求实现禁止它们不仅会破坏现有代码,而且还会使得这些实现无法生成与预标准 C 中可能的效率相当的代码(因为应用程序代码必须更改以传递无用的参数,然后编译器必须为其生成代码)。将此类用法定义为未定义行为可以使实现不必支持它,同时仍然允许实现在方便的情况下支持它。


1

虽然不是对你问题的直接回答,但编译器只是简单地生成汇编代码,将值推入堆栈中,然后调用函数。

例如(使用VS-2013编译器):

mov         esi,esp
push        7
call        dword ptr [h1]

如果您在此函数中添加一个本地变量,则可以使用其地址来查找每次调用该函数时传递的值。
例如(使用VS-2013编译器):
int f()
{
    int a = 0;
    int* p1 = &a + 4; // *p1 == 1
    int* p2 = &a + 5; // *p2 == 2
    int* p3 = &a + 6; // *p3 == 3
    return a;
}

int main()
{
    int(*h1)(int);
    h1 = f;
    return h1(1,2,3);
}

因此,实质上,使用附加参数调用函数是完全安全的,因为它们仅在程序计数器设置为函数地址之前被推入堆栈中(在可执行映像的代码部分中)。

当然,有人可能会声称这可能导致堆栈溢出,但这可以在任何情况下发生(即使传递的参数数量与声明的参数数量相同)。


-1

对于没有声明参数的函数,编译器不会推断任何参数/参数类型。以下代码本质上是相同的:

int f()
{
    return 9;
}

int main()
{
    return f(7, 8, 9);
}

我相信这与变长参数支持的底层方式有关,而 () 基本上与 (...) 相同。仔细查看生成的对象代码可以发现,对 f() 的参数仍然被推送到调用函数所使用的寄存器中,但由于它们在函数定义中被引用,因此在函数内部并不使用。如果您想声明一个不支持参数的参数,则最好将其编写为:

int f(void)
{
    return 9;
}

int main()
{
    return f(7, 8, 9);
}

这段代码在GCC中将无法通过编译,出现以下错误:
In function 'main':
error: too many arguments to function 'f'

1
不,()(...)并不基本相同,尽管它们可能以相同的方式实现。使用()(...)定义的两个函数是不兼容的;在没有可见原型的情况下调用可变参数函数具有未定义的行为。(出于历史原因和满足ABI的要求,大多数C编译器都使用相同的调用约定。) - Keith Thompson
这与函数指针主题没有太多关系(除了可能说明函数指针具有“兼容”类型的措辞与实际调用函数的行为不同之外)。 - M.M

-4
尝试在函数声明之前使用 __stdcall - 它将无法编译。
原因是函数调用默认为 __cdecl。这意味着(除其他功能外)调用者在调用后清除堆栈。因此,调用函数可以将任何内容推送到堆栈上,因为它知道已经推送了什么并且会正确地清除堆栈。
__stdcall 的意思是(除其他事项外)被调用者将清除堆栈。所以参数数量必须匹配。
...符号告诉编译器参数数量是可变的。如果声明为 __stdcall,则会自动替换为 __cdecl,并且仍然可以使用任意多个参数。

这就是编译器发出警告而不是停止的原因。
示例:
错误:堆栈已损坏。
#include <stdio.h>

void __stdcall allmyvars(int num) {
    int *p = &num + 1;
    while (num--) {
        printf("%d ", *p);
        p++;
    }  
}

void main() {
    allmyvars(4, 1, 2, 3, 4);
}

工作正常

#include <stdio.h>

void allmyvars(int num) {
    int *p = &num + 1;
    while (num--) {
        printf("%d ", *p);
        p++;
    }  
}

void main() {
    allmyvars(4, 1, 2, 3, 4);
}

在这个例子中,您具有正常行为,其与标准不相互依赖。您声明了函数指针,然后将其分配,并导致隐式类型转换。我已经写了它为什么可以工作。在c中,您也可以编写:
int main() {
  int *p;
  p = (int (*)(void))f; // why is this allowed?      
  ((int (*)())p)();
  return ((int (*)())p)(7);
}

它仍然是标准的一部分,但当然还有其他部分的标准。即使您将指向函数的指针分配给指向整数的指针,也不会发生任何事情。


1
-1,标准对stdcall和调用约定一无所知。 - Matteo Italia
没错。但是答案有什么问题吗?不是正确的吗? - Ivan Ivanov
1
重点在于它没有回答问题,对我来说这是一个“标准解释”的请求。不清楚你在这方面展示了什么观点,如果你使用特定的非标准扩展,那么一切都会崩溃,但问题并不是“它是否在VC++中可以工作”,而是“它是否确保能在普通C中运行?是否应该发出警告?” 对于这些问题,只有引用标准才能给出权威答案。 - Matteo Italia
什么是 PLAIN C?请告诉我普通 C 编译器的名称。如果您有 PLAIN C 的编译器,只需在此编译器上编译项目即可。如果它被编译,则可以正常工作;如果未编译,则此代码无效。我解释了它为什么有效,而不是说这是一个标准。您已经阅读了标准的一部分。如果还有疑问 - 再次阅读。它是明确无误的。 - Ivan Ivanov
“Plain C”是标准规定的内容。实际上并没有纯粹的C编译器(或者说,如果你愿意,没有符合定义正确的参考实现),这就是为什么这种问题不能通过在特定实现上执行测试来回答(因为这些测试可能是错误的——当涉及到语言的尘土飞扬的角落时,这种情况并不罕见),特别是如果你测试的是无关的东西。 - Matteo Italia
它之所以能够工作,是因为隐式类型转换。不,它在标准中是不被允许的。但在这种情况下,标准与此无关,因为您使用适当的参数调用适当的指针。例如,h1 = (int (*)(int))f; 不会产生错误和警告。但为什么它能工作?我在上面回答了这个问题。但为什么会发生这种情况-因为链接器不知道它。该怎么办?提示编译器声明调用约定。 - Ivan Ivanov

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