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

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

a->f 的值类别是纯右值 [expr.ref]/6.3.2 and [basic.life]/7 只禁止对 glvalues 引用生命周期后对象的非静态成员函数调用.这是否意味着调用有效? (编辑:正如评论中所讨论的,我可能误解了 [basic.life]/7,它可能确实适用于此。)

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


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


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

  1. a->A(); a->f(0):UB,因为在其生命周期之外对 a 的非静态成员调用。 (不过请参阅下面的编辑)
  2. delete a; a->f(0):同上
  3. 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 中说:

The lifetime of an object o of type T ends when:

  • if T is a class type with a non-trivial destructor (15.4), the destructor call starts

[...]

current draft 表示:

The lifetime of an object o of type T ends when:

[...]

  • if T is a class type, the destructor call starts, or

[...]

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


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


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

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


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

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

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

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

的 UB

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

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

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

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

所以问 a->f 什么时候是 "evaluated" 是一个毫无意义的问题:没有 a->f 无价值的独特评估步骤,无类型表达式.

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

编辑:

实际上这比我写的更糟糕,表达式a->f有一个假"type":

E1.E2 is “function of parameter-type-list cv returning T”.

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

int ::f() const; // meaningless

而在 class f() const 中并不意味着“参数类型列表的函数=() with cv=const”,它意味着成员函数(参数类型- list=() with cv=const)。没有 proper 声明符来表示 "function of parameter-type-list cv"。它只能存在于 class; 中没有可以声明的类型 "function of parameter-type-list cv returning T" 或真正的可计算表达式可以具有的类型。

除了别人说的:

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() 中使用任何非静态成员变量可能是未定义的行为,因为您在 they 之后访问 them由编译器生成的 ~A()(隐含地)销毁。如果它类似于 std::stringstd::vector.

,则可能会导致 运行 时间错误

删除一个

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

尽管如此,代码仍然 运行 没有错误,因为函数 f() 是空的。如果它访问任何成员变量,它可能会崩溃或导致随机结果,因为 a 的内存已被释放。

新(一)A

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

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

如果 A 不包含任何具有构造函数分配内存等的对象,它将 运行 正常。否则它可能会导致内存泄漏等,但是 f() 可以很好地访问它们的 "second" 副本。

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

#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,我得到以下控制台输出和退出程序状态:

控制台输出

5

IDE退出状态输出

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

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


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

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

控制台输出

8

IDE退出状态输出

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

控制台输出

8
10

IDE退出状态输出

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

此处显示 a.xa->~A() 被调用后保持其值,因为 newA 上被调用并且 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。但是,从语言的角度来看,当涉及到标准时,我也不会依赖这种类型的代码。上面也没有考虑 class 何时具有堆栈自动存储和动态堆分配的内部指针,以及构造函数是否对这些内部对象调用 new 并且析构函数对它们调用 delete 。

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

在 C++20(的计划)之前,琐碎的析构函数确实什么都不做,甚至不会结束对象的生命周期。所以问题是,呃,微不足道,除非我们假设一个非微不足道的析构函数或更强大的东西,比如 delete.

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

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