【问题标题】:Why does the spec prohibit passing class types to variable-argument C++ functions?为什么规范禁止将类类型传递给可变参数 C++ 函数?
【发布时间】:2016-08-24 19:06:04
【问题描述】:

将非 POD 传递给变量参数函数(例如 printf)是未定义的行为(12),但我不明白为什么 C++ 标准是这样设置的。变量 arg 函数中是否有任何固有的东西阻止它们接受类作为参数?

variable-arg 被调用者确实对它们的类​​型一无所知 - 但它也不知道它接受的内置类型或普通 POD。

此外,这些必须是 cdecl 函数,因此 调用者 可以负责,例如用于在通过时复制它们并在返回时销毁它们。

任何见解都将不胜感激。


编辑:我仍然看不出建议的可变参数语义不起作用的原因,但 zneak 的回答很好地证明了调整编译器以适应它需要什么 - 所以我接受了它。归根结底,这可能是一些历史故障。

【问题讨论】:

  • 现在,我们有了可变参数模板,那么为什么还要使用旧的省略号 C 参数呢?
  • @cpplearner:是的。该标准没有cdecl 函数的概念 - 但实际上对于可变参数函数,调用er 必须负责清理,因为调用ee 不会知道传递了多少个参数:printf("%s", "a", "b"); 是完全合法的。
  • 我的猜测:在过去的 C 语言中,编译器只理解函数参数的三种类型 - intdoublepointer。它能够将函数调用中使用的任何参数转换为上述类型之一。它不能那样做,你是 SOL。因此,structs 完全不被视为可用于可变参数函数调用的类型。
  • 这只是 C++03 及更早版本中的 UB。 C++11 有条件地支持它。
  • @TobySpeight 对此进行了更深入的讨论:stackoverflow.com/questions/2512746/…

标签: c++ language-lawyer variadic-functions


【解决方案1】:

调用约定确实指定了谁进行低级堆栈舞蹈,但没有说明谁负责“高级”C++ 簿记。至少在 Windows 上,按值接受对象的函数负责调用其析构函数,即使它不负责存储空间。例如,如果你构建这个:

#include <stdio.h>

struct Foo {
    Foo() { puts("created"); }
    Foo(const Foo&) { puts("copied"); }
    ~Foo() { puts("destroyed"); }
};

void __cdecl x(Foo f) { }

int main() {
    Foo f;
    x(f);
    return 0;
}

你得到:

x:
    mov     qword ptr [rsp+8],rcx
    sub     rsp,28h
    mov     rcx,qword ptr [rsp+30h]
    call    module!Foo::~Foo (00000001`400027e0)
    add     rsp,28h
    ret

main:
    sub     rsp,48h
    mov     qword ptr [rsp+38h],0FFFFFFFFFFFFFFFEh
    lea     rcx,[rsp+20h]
    call    module!Foo::Foo (00000001`400027b0) # default ctor
    nop
    lea     rax,[rsp+21h]
    mov     qword ptr [rsp+28h],rax
    lea     rdx,[rsp+20h]
    mov     rcx,qword ptr [rsp+28h]
    call    module!Foo::Foo (00000001`40002780) # copy ctor
    mov     qword ptr [rsp+30h],rax
    mov     rcx,qword ptr [rsp+30h]
    call    module!x (00000001`40002810)
    mov     dword ptr [rsp+24h],0
    lea     rcx,[rsp+20h]
    call    module!Foo::~Foo (00000001`400027e0)
    mov     eax,dword ptr [rsp+24h]
    add     rsp,48h
    ret

注意main 如何构造两个Foo 对象但只销毁一个; x 负责另一个。如果将对象作为 vararg 传递,那显然是行不通的。


编辑:将对象传递给具有可变参数的函数的另一个问题是,在其当前形式中,无论调用约定如何,“正确的事情”都需要两个副本,而正常的参数传递只需要一个。除非 C++ 通过允许传递和/或接受对对象的引用来扩展 C 可变参数函数(这极不可能发生,因为 C++ 使用可变参数模板以类型安全的方式解决了相同的问题),调用者需要制作对象的一份副本,va_arg 只允许被调用者获取该副本的副本。

Microsoft 的 CL 试图在va_arg 站点上使用该按位副本的一个按位副本和一个完整副本构造来逃避,但这可能会产生令人讨厌的后果。考虑这个例子:

struct foo {
    char* ptr;

    foo(const char* ptr) { this->ptr = _strdup(ptr); }
    foo(const foo& that) { ptr = _strdup(that.ptr); }
    ~foo() { free(ptr); }

    void setPtr(const char* ptr) {
        free(this->ptr);
        this->ptr = _strdup(ptr);
    }
};

void variadic(foo& a, ...)
{
    a.setPtr("bar");

    va_list list;
    va_start(list, a);
    foo b = va_arg(list, foo);
    va_end(list);

    printf("%s %s\n", a.ptr, b.ptr);
}

int main() {
    foo f = "foo";
    variadic(f, f);
}

在我的机器上,这会打印“bar bar”,即使如果我有一个非可变函数的第二个参数通过复制接受另一个foo,它也会打印“foo bar”。这是因为f 的按位复制发生在mainvariadic 的调用点,但只有在调用va_arg 时才会调用复制构造函数。在两者之间,a.setPtr 使原始的f.ptr 值无效,但该值仍然存在于按位副本中,并且纯巧合_strdup 返回相同的指针(尽管其中包含一个新字符串)。相同代码的另一个结果可能是_strdup 中的崩溃。

请注意,这种设计非常适合 POD 类型;只有当构造函数和析构函数需要副作用时,它才会崩溃。

调用约定和参数传递机制不一定支持对象的非平凡构造和销毁的原始观点仍然存在:这正是这里发生的事情。


编辑:答案最初说构造和破坏行为是特定于 cdecl 的;它不是。 (谢谢科迪!)

【讨论】:

  • 感谢您的回答。如果是这种情况,std::string str("Hello");printf("%s", str) 怎么不会泄漏内存?我刚刚测试过,但没有 - 甚至没有调用 std::string 复制 ctor。
  • 这个答案中的术语有些误导。虽然在 Windows 平台上有一个__cdecl 调用约定,但它只存在于 32 位代码中。您的目标代码示例显然是 64 位代码,因为它使用 64 位寄存器。 Windows 上的 64 位平台没有 __cdecl。实际上,AMD64 只有两种调用约定:(a) 标准的 Microsoft 64 位调用约定,它没有名称,以及 (b) __vectorcall
  • 碰巧如果你传递一个带有析构函数的对象,32 位 __cdecl,如 32 位 __stdcall__fastcall,都会传输整个堆栈上的对象。这改变了对象的所有权,使接收者负责管理其生命周期。 Microsoft 64 位调用约定,如__vectorcall,在本质上是相同的,但在实现上不同,因为它在寄存器中传递包含析构函数的对象的副本,只有在使用所有寄存器时才回退到堆栈。
  • @OfekShilon 大多数 STL 实现 small string optimizations。在 MSVC 的情况下(至少对于 64 位),16 个字符以下的字符串不需要动态分配。我只有一台 Windows 机器在工作,所以我无法检查构造函数/析构函数的情况。
  • @OfekShilon,这是对 POD 唯一正确的做法,而不是优化。此机制对非 POD 类型的扩展需要作为可变参数传递的每个对象的两个完整副本。如果不更改 va_lists 在 C 中的工作方式,您可能无法解决双重复制问题。最终,可能没有比“它打破了一堆假设并且没有人足够关心”更好的答案。
【解决方案2】:

我正在记录这个,因为它太大而不能成为评论,而且追捕它相当耗时,所以没有其他人浪费时间去寻找这条路线。

该文本首先更改为类似于 2006 年 11 月 3 日发布的 N2134 中的标准草案中的当前措辞。

通过一些努力,我能够将措辞追溯到DR506

论文 J16/04-0167=WG21 N1727 建议将非 POD 对象传递给省略号是格式错误的。然而,在 Lillehammer 会议的讨论中,CWG 认为新批准的有条件支持的行为类别更合适。

引用的论文 (N1727) 对此主题几乎没有提及:

现有的措辞 (5.2.2¶7) 使得在函数调用中将非 POD 对象传递给省略号的行为未定义:

{截图}

CWG 再次认为没有理由不要求实施在这种情况下发出诊断。

但是,这并没有告诉我很多关于为什么它是开始的方式,这是你想知道的。对我来说,将时间倒回到第一次编写该语言的时间是不可能的,因为最古老的免费可用标准草案是从 2005 年开始的,并且已经有了您想知道的措辞,在此之前的所有标准要么需要身份验证,要么只是无内容。

【讨论】:

  • 在 C++98 和 C++03 中有“如果参数具有非 POD 类类型(第 9 条),则行为未定义。”。我想这是为了 C 兼容性:通过 varargs 传递结构的 C 代码应该继续工作。
【解决方案3】:

我猜问题是/是违反了类型安全。通常,在需要基类对象的地方传递派生类对象应该是安全的。如果基类对象被取值,那么派生类对象将被简单地切片。如果它由指针/引用获取 - 派生类对象的指针/引用在编译期间被正确调整。这不适用于变量参数函数,其中输入类型的解释由代码而不是编译器执行。

例子:

struct A { char c; };
struct B { int i; };
struct D : A, B { double d; };

// This is similar to printf, but also handles the
// format specifier %b assuming an object of type B
void non_pod_printf(const char* fmt, ...);

D d1, d2;

// I bet that the code inside non_pod_printf will fail to correctly
// handle the d1 and d2 arguments even though the language rules
// ensure that D is a B
non_pod_printf("%d %b %b", 123, d1, d2);

编辑

正如现在已删除的评论所指出的,上面示例中的ABD 实际上是 POD 类型。但是,我要提请您注意的问题与继承有关,虽然允许 POD 类型,但在大多数情况下涉及非 POD 类型。

【讨论】:

    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 2014-04-03
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2023-04-02
    • 2017-04-15
    • 1970-01-01
    相关资源
    最近更新 更多