在 C++17 中在对象的生命周期之外调用非静态成员函数

Posted

技术标签:

【中文标题】在 C++17 中在对象的生命周期之外调用非静态成员函数【英文标题】:Calling non-static member function outside of object's lifetime in C++17 【发布时间】:2020-01-23 17:48:21 【问题描述】:

以下程序在 C++17 及更高版本中是否有未定义的行为?

struct A 
    void f(int)  /* Assume there is no access to *this here */ 
;

int main() 
    auto a = new A;
    a->f((a->~A(), 0));

C++17 保证 a->f 在评估调用的参数之前被评估为 A 对象的成员函数。因此,来自-> 的间接定义是明确的。但在输入函数调用之前,会评估参数并结束 A 对象的生命周期(但请参见下面的编辑)。调用是否仍有未定义的行为?是否可以通过这种方式在对象的生命周期之外调用其成员函数?

a->f 的值类别是[expr.ref]/6.3.2 的prvalue,而[basic.life]/7 只不允许在引用死后对象的glvalues 上调用非静态成员函数。这是否意味着呼叫有效? (编辑:正如 cmets 中所讨论的,我可能误解了 [basic.life]/7,它可能确实适用于此。)

如果我将析构函数调用 a->~A() 替换为 delete anew(a) A(使用 #include<new>),答案是否会改变?


对我的问题进行一些详细的编辑和澄清:


如果我把成员函数调用和析构函数/delete/placement-new 分成两个语句,我想答案就很清楚了:

    a->A(); a->f(0):UB,因为在其生命周期之外对 a 的非静态成员调用。 (不过,请参阅下面的编辑) delete a; a->f(0):同上 new(a) A; a->f(0):定义明确,调用新对象

但是,在所有这些情况下,a->f 都在第一个相应语句之后进行排序,而在我最初的示例中,这个顺序是相反的。我的问题是这种反转是否允许改变答案?


对于 C++17 之前的标准,我最初认为所有三种情况都会导致未定义的行为,因为 a->f 的评估取决于 a 的值,但相对于参数的评估而言是无序的对a 产生副作用。但是,仅当标量值存在实际副作用时,这才是未定义的行为,例如写入标量对象。但是,没有写入标量对象,因为A 是微不足道的,因此我也会对在 C++17 之前的标准的情况下究竟违反了哪些约束感兴趣。特别是,placement-new 的情况我现在似乎不清楚。


我刚刚意识到关于对象生命周期的措辞在 C++17 和当前草案之间发生了变化。在 n4659(C++17 草案)[basic.life]/1 中说:

类型 T 的对象 o 的生命周期结束于:

如果 T 是一个类 类型与非平凡的析构函数 (15.4),析构函数调用开始

[...]

current draft 说:

类型 T 的对象 o 的生命周期在以下时间结束:

[...]

如果 T 是类类型,则析构函数调用开始,或者

[...]

因此,我想我的示例在 C++17 中确实有明确定义的行为,但不是他当前的 (C++20) 草案,因为析构函数调用很简单,A 对象的生命周期不是t 结束。我也希望对此作出澄清。对于用 delete 或 placement-new 表达式替换析构函数调用的情况,我最初的问题仍然适用于 C++17。


如果f 在其主体中访问*this,那么在析构函数调用和删除表达式的情况下可能存在未定义的行为,但是在这个问题中,我想关注调用本身是否有效。 但是请注意,我的问题与placement-new 的变化可能不会对f 中的成员访问有问题,具体取决于调用本身是否是未定义的行为。但是在那种情况下,可能会有一个后续问题,特别是对于placement-new的情况,因为我不清楚函数中的this是否会始终自动引用新对象,或者它是否可能需要潜在地成为std::laundered(取决于A 的成员)。


虽然A 确实有一个微不足道的析构函数,但更有趣的情况可能是它有一些副作用,编译器可能希望为优化目的做出假设。 (我不知道是否有任何编译器使用类似的东西。)因此,我欢迎A 也有非平凡析构函数的情况的答案,特别是如果两种情况的答案不同。

此外,从实际的角度来看,微不足道的析构函数调用可能不会影响生成的代码和(不太可能?)基于未定义行为假设的优化,所有代码示例很可能生成在大多数编译器上按预期运行的代码。我更感兴趣的是理论,而不是实际的观点。


这个问题旨在更好地理解语言的细节。我不鼓励任何人编写这样的代码。

【问题讨论】:

这是“如果措辞允许,那就是措辞上的缺陷”之类的问题。 "a->f 的值类别是纯右值...只不允许非静态成员函数调用 glvalues" -> 的左操作数。 我期待 UB。想象一下f 访问了一些成员。 @uneven_mark eel.is/c++draft/basic.life#7.2 "如果程序具有未定义的行为,如果: ... glvalue 用于调用对象的非静态成员函数" - @987654356 @总是是prvalue,所以"glvalue is used to call ... member function"肯定是指*ptr,而不是ptr->member_func @HolyBlackCat 啊,是的,我想这是有道理的。然后我猜它适用于这里并且确实会在我提到的所有变体中导致未定义的行为。 【参考方案1】:

确实,在 C++20 (计划)之前,琐碎的析构函数根本不做任何事情,甚至不会结束对象的生命周期。所以问题是,呃,微不足道的,除非我们假设一个非平凡的析构函数或像delete 这样更强大的东西。

在这种情况下,C++17 的排序没有帮助:调用(不是类成员访问)使用指向对象 (to initialize this) 的指针,违反了rules for out-of-lifetime pointers。

旁注:如果只有一个顺序未定义,那么 C++17 之前的“未指定顺序”也是如此:如果未指定行为的 any of the possibilities 是未定义行为,则该行为未定义。 (您如何判断选择了明确定义的选项?未定义的选项可以模仿它并然后释放鼻恶魔。)

【讨论】:

如果问题出在初始化时指针的使用上,那么我假设a->f((new(a) A, 0))只要满足[basic.life]/8的要求就有效? 我认为 [basic.life]/6.2 不适用,因为我解释了上一句 “允许通过这样的指针进行间接访问,但生成的左值只能以有限的方式使用, 如下所述" 暗示 UB 仅在存在间接时才适用。然而,this 的初始化没有间接寻址,a->fa 间接寻址在调用之前单独执行。 似乎 [basic.life]/6 仅适用于释放之前(即不适用于a->f((delete a, 0)))。 this 的初始化可能会被实现定义为使用无效指针,或者在这种情况下是否有其他原因导致 UB? @uneven_mark:是的,我认为只要a(也)指向新对象,placement-new 案例就可以工作。关于间接的陈述是指第 7 段(关于左值)。使用delete,初始化确实会具有实现定义的行为,但这并不重要,因为使用它调用是未定义的。【参考方案2】:

后缀表达式a->f排序在任何参数的评估之前(它们相对于彼此的顺序是不确定的)。 (见[expr.call])

参数的计算在函数体之前排序(即使是内联函数,参见[intro.execution])

这意味着调用函数本身并不是未定义的行为。但是,访问任何成员变量或调用其中的其他成员函数将是每个 [basic.life] 的 UB。

所以结论是,这个特定的实例按照措辞是安全的,但总的来说是一种危险的技术。

【讨论】:

您认为 [basic.life]/7 如何参与其中?查看我的编辑和 cmets。 如果您尝试访问函数内的成员,basic.life 会发挥作用。 你确定吗?甚至认为fvoid A::f(int /*dummy*/) /* no-op*/ 不是f 在被破坏的a 上调用时仍然是UB(不是在这个复杂的场景中的调用点,而是在成员函数中)? 好的,感谢您的检查。根据标准,我不确定代码(尽管没有操作)是否为 UB。 嗯,delete this; 一直被很好地定义(在与任何delete p; 完全相同的假设下),通常被认为是“危险的”,但在现实世界中有用途,预计会得到所有人的支持编译器。这个愚蠢的游戏OTOH没有我能看到的真正用途,并且可能不会被任何人支持。【参考方案3】:

您似乎认为a->f(0) 有这些步骤(按照最新的 C++ 标准的顺序,按照以前版本的逻辑顺序):

评估*a 评估a->f(所谓的绑定成员函数) 评估0 在参数列表(0)上调用绑定的成员函数a->f

但是a->f 既没有值也没有类型。它本质上是一个非事物,一个无意义的语法元素需要,因为语法分解了成员访问和函数调用,甚至在一个成员函数调用上 by define结合了成员访问和函数调用

所以问a->f 何时被“评估”是一个毫无意义的问题:对于a->f 无值、无类型的表达式,没有明确的评估步骤

因此,任何基于对非实体评估顺序的讨论的推理也是无效的。

编辑:

其实这比我写的还要糟糕,a->f这个表达式有一个假的“类型”:

E1.E2 是“参数类型列表 cv 返回 T 的函数”。

"function of parameter-type-list cv" 甚至不是类外的有效声明符:不能像在全局声明中那样将 f() const 作为声明符:

int ::f() const; // meaningless

并且在类f() const 内部并不意味着“参数类型列表=()的函数,cv=const”,它意味着成员函数(参数类型列表=()的成员函数,cv=const ). 没有 proper 声明符用于正确的“参数类型列表 cv 的函数”。它只能存在于类中;没有类型“参数类型列表的函数” cv 返回 T" 可以声明或真正的可计算表达式可以具有。

【讨论】:

这是真的吗? The expression E1->E2 is converted to the equivalent form (*(E1)).E2if E1.E2 refers to a non-static member function [...] then E1.E2 is a prvalue. [...] The type of E1.E2 is “function of parameter-type-list cv returning T”. 同一段禁止使用该表达式,除了作为调用的左侧,但它似乎确实为它分配了值类别和类型。我是不是误会了? @uneven_mark 编辑答案以添加所谓的“表达式类型”(笑) 有趣的是,您可以使用 function of parameter-type-list cv 返回 T 作为类型并声明成员函数:typedef void F() const; struct A F f; 声明了一个 const 成员函数,尽管您不能使用F 来定义函数。并且 F 本身可以用作类型,例如在模板中。但我明白你的观点,这种类型的实际表达基本上只在调用时才有意义。 @uneven_mark 在多年研究 C++ 细节的过程中,我从来没有想过成员函数声明符的性质以及如何在类之外使用成员属性! std 的文本甚至没有讨论将成员函数绑定到对象,然后在 so called prvalue 上评估函数调用运算符。它有点脏而且不规则。请注意,浮点纯右值也是不规则的,因为它们可能无法由实际对象表示,因此它们不能是左值(例如,具有无限精度的中间值的融合操作)。【参考方案4】:

除了别人说的:

a->~A();删除一个;

此程序存在内存泄漏,从技术上讲,它本身并不是未定义的行为。 但是,如果您调用 delete a; 来阻止它 - 那应该是未定义的行为,因为 delete 会第二次调用 a->~A() [第 12.4/14 节]。

a->~A()

否则实际上这就像其他人所建议的那样 - 编译器会按照 A* a = malloc(sizeof(A)); a->A(); a->~A(); a->f(0); 的行生成机器代码。 由于没有成员变量或虚函数,所有三个成员函数都是空的 (return;) 并且什么也不做。指针a 甚至仍然指向有效内存。 它会运行,但调试器可能会抱怨内存泄漏。

但是,在 f() 中使用任何非静态成员变量可能是未定义的行为,因为您在 它们 被编译器生成的 @ (隐式)销毁之后访问 它们 987654328@。如果它类似于std::stringstd::vector,这可能会导致运行时错误。

删除一个

如果您将 a->~A() 替换为调用 delete a; 的表达式,那么我相信这将是未定义的行为,因为此时指针 a 不再有效。

尽管如此,由于函数 f() 是空的,因此代码应该仍然可以正常运行。如果它访问任何成员变量,它可能已经崩溃或导致随机结果,因为a 的内存已被释放。

新的(a) A

auto a = new A; new(a) A; 本身就是未定义的行为,因为您第二次调用 A() 以获得相同的内存。

在这种情况下,单独调用 f() 将是有效的,因为 a 存在但构造 a 两次是 UB。

如果A 不包含任何带有分配内存的构造函数的对象,它将运行良好。否则可能会导致内存泄漏等,但 f() 可以访问它们的“第二个”副本。

【讨论】:

new(a) A 是一个全新的展示位置,完全没问题。由于析构函数是微不足道的,我也不需要事先调用它。我也认为(但不确定)多次调用微不足道的析构函数也可以,请参阅我的问题的编辑。 @uneven_mark 即使有一个非平凡的 dtor,你也只需要在它做任何有用的工作时调用它。【参考方案5】:

我不是语言律师,但我使用了您的代码 sn-p 并稍作修改。我不会在生产代码中使用它,但这似乎会产生有效的定义结果......

#include <iostream>
#include <exception>

struct A 
    int x5;
    void f(int)
    int g()  std::cout << x << '\n'; return x; 
;

int main() 
    try 
        auto a = new A;
        a->f((a->~A(), a->g()));
    catch(const std::exception& e) 
        std::cerr << e.what();
        return EXIT_FAILURE;
    
    return EXIT_SUCCESS;

我正在运行 Visual Studio 2017 CE,编译器语言标志设置为 /std:c++latest,我的 IDE 版本是 15.9.16,我得到以下控制台输出和退出程序状态:

控制台输出

5

IDE 退出状态输出

The program '[4128] Test.exe' has exited with code 0 (0x0).

所以这似乎是在 Visual Studio 的情况下定义的,我不确定其他编译器将如何处理它。正在调用析构函数,但变量 a 仍在动态堆内存中。


让我们尝试另一个轻微的修改:

#include <iostream>
#include <exception>

struct A 
    int x5;
    void f(int)
    int g(int y)  x+=y; std::cout << x << '\n'; return x; 
;

int main() 
    try 
        auto a = new A;
        a->f((a->~A(), a->g(3)));
    catch(const std::exception& e) 
        std::cerr << e.what();
        return EXIT_FAILURE;
    
    return EXIT_SUCCESS;

控制台输出

8

IDE 退出状态输出

The program '[4128] Test.exe' has exited with code 0 (0x0).

这次我们不要再换班了,以后再打电话给a的成员……

int main() 
    try 
        auto a = new A;
        a->f((a->~A(), a->g(3)));
        a->g(2);
     catch( const std::exception& e ) 
        std::cerr << e.what();
        return EXIT_FAILURE;
    
    return EXIT_SUCCESS;

控制台输出

8
10

IDE 退出状态输出

The program '[4128] Test.exe' has exited with code 0 (0x0).

这里似乎 a.x 在调用 a-&gt;~A() 后保持其值,因为在 A 上调用了 new 并且尚未调用 delete


如果我删除 new 并使用堆栈指针而不是分配的动态堆内存,则更是如此:

int main() 
    try 
        A b;
        A* a = &b;    
        a->f((a->~A(), a->g(3)));
        a->g(2);
     catch( const std::exception& e ) 
        std::cerr << e.what();
        return EXIT_FAILURE;
    
    return EXIT_SUCCESS;

我还在:

控制台输出

8
10

IDE 退出状态输出


当我将编译器的语言标志设置从 /c:std:c++latest 更改为 /std:c++17 时,我得到了完全相同的结果。

我从 Visual Studio 中看到的内容似乎定义明确,而在我所展示的上下文中没有产生任何 UB。然而,从语言的角度来看,当它涉及标准时,我也不会依赖这种类型的代码。上面也没有考虑类何时具有堆栈自动存储和动态堆分配的内部指针,以及构造函数是否对这些内部对象调用 new 而析构函数对它们调用 delete 。

除了编译器的语言设置之外,还有许多其他因素,例如优化、约定调用和其他各种编译器标志。很难说,我没有完整的最新起草标准的可用副本来更深入地调查这个问题。也许这可以帮助您、其他能够更彻底地回答您的问题的人以及其他读者形象化这种行为。

【讨论】:

感谢您的努力,一些cmet会跟进: 关于标准,您正在测试的是一个稍微不同的问题。在(a-&gt;~A(), a-&gt;g()) 中,第二个调用总是排在第一个之后。因此,如果a-&gt;~A() 结束对象的生命周期,则此表达式本身会导致未定义的行为。正如我在问题中引用的那样,这可能取决于析构函数是否微不足道以及标准版本。 同样很清楚,我的示例和您的示例很可能被编译为按预期工作的东西,因为析构函数实际上并没有做任何事情。然而,关于编译器可以进行的优化,是否存在未定义行为的问题更有趣。例如,当输入成员函数时,是否允许假设在*this 上尚未完成具有全局副作用的析构函数?我不确定是否有任何编译器使用这种优化,但原则上,如果我的示例是未定义的行为,它们可以使用。 @uneven_mark 感谢您指出这一点;我更正了! @uneven_mark 我倾向于同意,但我不能说这是我不知道的 100% 事实。然而,我试图说明定义的琐碎析构函数的基本示例。现在,如果在堆上创建的类中有对象并且析构函数确实起作用并且这些内部对象被删除,那么我认为指向这些对象的*this指针将变得无效并导致UB。这就是为什么我确实声明我不会依赖它,也不会在生产代码中使用它。

以上是关于在 C++17 中在对象的生命周期之外调用非静态成员函数的主要内容,如果未能解决你的问题,请参考以下文章

C语言中,哪种存储类的作用域与生命周期是不一致的?

27 类的生命周期

c++类中 各种成员的生命周期?

在调用析构函数之前对象的生命周期已经结束?

java中在一个类中定义的一个静态方法,怎么引用时可以直接用,不用对象.方法,也不用类.方法?

是否允许调用 [self release] 来控制对象生命周期?