繁体   English   中英

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

[英]Calling non-static member function outside of object's lifetime in C++17

以下程序在 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 object 的成员 function 。 因此,来自->的间接定义是明确的。 但在输入 function 调用之前,会评估参数并结束A object 的生命周期(但请参见下面的编辑)。 调用是否仍有未定义的行为? 是否可以通过这种方式在其生命周期之外调用 object 的成员 function ?

a->f的值类别是[expr.ref]/6.3.2的 prvalue,并且[basic.life]/7仅不允许非静态成员function调用引用生命周期后 object 的 glvalues。 这是否意味着呼叫有效? (编辑:正如评论中所讨论的,我可能误解了 [basic.life]/7,它可能确实适用于此。)

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


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


如果我将成员 function 调用和 destructor/delete/placement-new 分成两个语句,我认为答案很明确:

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

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


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


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

T 型 object o 的生命周期在以下情况下结束:

  • 如果 T 是具有非平凡析构函数 (15.4) 的 class 类型,则析构函数调用开始

[...]

目前的草案说:

T 型 object o 的生命周期在以下情况下结束:

[...]

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

[...]

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


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


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

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


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

后缀表达式a->f在任何 arguments 的评估之前进行排序(相对于彼此不确定地排序)。 (见[expr.call])

arguments 的评估在 function 的主体之前排序(甚至内联函数,请参阅 [intro.execution])

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

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

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

在这种情况下,C++17 的排序没有帮助:调用(不是 class 成员访问)使用指向 object 的指针(来初始化this ),违反了过期指针的规则

旁注:如果只有一个订单未定义,那么 C++17 之前的“未指定订单”也是如此:如果未指定行为的任何可能性是未定义行为,则该行为未定义。 (你怎么知道选择了明确定义的选项?未定义的可以模仿它,然后释放鼻恶魔。)

您似乎假设a->f(0)具有以下步骤(按照最新的 C++ 标准的顺序,对于以前的版本按逻辑顺序排列):

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

但是a->f既没有值也没有类型。 它本质上是一个无意义的语法元素,因为语法分解了成员访问和 function 调用,即使在成员 function 调用上,它通过定义组合成员访问和 ZC1C425268E68385D1CAB50 调用

所以问什么时候a->f被“评估”是一个没有意义的问题:对于a->f value-less, type-less expression 没有一个独特的评估步骤

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

编辑:

实际上这比我写的更糟糕,表达式a->f有一个虚假的“类型”:

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

“参数类型列表 cv 的函数”甚至不是 class 之外的有效声明符:不能像在全局声明中那样将f() const作为声明符:

int ::f() const; // meaningless

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

除了别人说的:

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

这个程序有一个 memory 泄漏,它本身在技术上不是未定义的行为。 但是,如果您调用delete a; 为了防止它 - 这应该是未定义的行为,因为delete会第二次调用a->~A() [第 12.4/14 节]。

a->~A()

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

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

删除一个

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

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

新的(a) A

auto a = new A; new(a) A; 本身就是未定义的行为,因为您为同一个 memory 第二次调用A()

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

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

我不是语言律师,但我拿走了你的代码片段并稍微修改了一下。 我不会在生产代码中使用它,但这似乎会产生有效的定义结果......

#include <iostream>
#include <exception>

struct A {
    int x{5};
    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 ,我得到以下控制台 output 并退出程序状态:

控制台 output

5

IDE 退出状态 output

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

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


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

#include <iostream>
#include <exception>

struct A {
    int x{5};
    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;
}

控制台 output

8

IDE 退出状态 output

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

这次我们不要再更改class,而是让我们之后打电话给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;
}

控制台 output

8
10

IDE 退出状态 output

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

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


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

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;
}

我仍然得到:

控制台 output

8
10

IDE 退出状态 output


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

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

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

暂无
暂无

声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM