以下程序在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 的非静态成员函数调用,这指的是后世宾语。这是否表示通话有效? (编辑:如评论中所讨论,我可能会误解[basic.life] / 7,它可能确实适用于此。)
如果我将析构函数调用a->~A()
替换为delete a
或new(a) A
(替换为#include<new>
),答案会改变吗?
对我的问题进行一些详尽的编辑和澄清:
如果我将成员函数调用和destructor / delete / placement-new分为两个语句,我认为答案很明确:
a->A(); a->f(0)
:UB,因为a
生命周期之外对delete a; a->f(0)
进行的非静态成员调用。 (不过,请参见下面的编辑内容)new(a) A; a->f(0)
:与上面相同a->f
:定义明确,请调用新对象但是,在所有这些情况下,a->f
都在相应的第一个语句之后排序,而在我的最初示例中,此顺序相反。我的问题是这种逆转是否允许答案改变?
对于C ++ 17之前的标准,我最初认为这三种情况都会导致不确定的行为,已经是因为a
的评估取决于a
的值,但是相对于评估却没有顺序在A
上产生副作用的参数。但是,仅当标量值存在实际副作用时,这才是未定义的行为。写入标量对象。但是,没有写入任何标量对象,因为A
是微不足道的,因此,我也将对在C ++ 17之前的标准中完全违反了什么约束感兴趣。特别是,现在对我来说不清楚新安置的情况。
我刚刚意识到关于对象生存期的措辞在C ++ 17和当前草案之间发生了变化。在n4659(C ++ 17草案)中,[basic.life] / 1说:
类型为T的对象o的生存期在以下情况下结束:
- 如果T是一类 如果类型为非平凡的析构函数(15.4),则析构函数调用开始
[...]
类型为T的对象o的生存期在以下情况下结束:
[...]
- 如果T是类类型,则析构函数调用开始,或者
[...]
因此,我想我的示例在C ++ 17中确实具有明确定义的行为,但在当前(C ++ 20)草案中却没有,因为析构函数调用很简单并且f
对象的生存期很短没有结束。我也希望对此进行澄清。对于用delete或placement-new表达式替换析构函数调用的情况,即使是C ++ 17,我最初的问题仍然存在。
如果*this
在其主体中访问f
,则在析构函数调用和delete表达式的情况下可能存在未定义的行为,但是在此问题中,我想集中讨论调用本身是否为有效与否。
但是请注意,根据呼叫本身是否是未定义的行为,我的问题使用新的Placement的变化可能不会对this
中的成员访问产生影响。但是在那种情况下,可能会有一个后续问题,特别是对于新放置的情况,因为我不清楚,函数中的std::launder
是否会始终自动引用新对象,或者是否需要可能A
(取决于成员A
拥有)。
尽管A
确实有一个琐碎的析构函数,但更有趣的情况可能是它具有一些副作用,编译器可能会出于优化目的而对其进行假设。 (我不知道是否有任何编译器使用这样的东西。)因此,对于{{1}}也具有非平凡的析构函数的情况,我欢迎大家提供答案,尤其是在两种情况下答案不同的情况下。>
此外,从实际角度看,琐碎的析构函数调用可能不会影响生成的代码,并且(不太可能?)基于未定义行为假设的优化,所有代码示例都极有可能生成可在大多数编译器上按预期运行的代码。我对理论而不是实践观点更感兴趣。
此问题旨在更好地理解语言的细节。我不鼓励任何人编写这样的代码。
答案 0 :(得分:7)
后缀表达式a->f
在评估任何参数(相对于彼此不确定地排序)之前被排序。 (请参阅[expr.call])
对参数的求值在函数主体之前 排序(甚至是内联函数,请参见[intro.execution])
这意味着,调用函数本身不是未定义的行为。但是,访问每个成员变量或调用其中的其他成员函数将是每个[basic.life]的UB。
因此得出的结论是,根据措辞,此特定实例是安全的,但通常来说是一种危险的技术。
答案 1 :(得分:7)
的确,在C ++ 20的计划之前,琐碎的析构函数什么都不做,甚至没有结束对象的生命期。因此,问题是,呃,琐碎的,除非我们假设一个非琐碎的析构函数或更强大的delete
之类的东西。
在这种情况下,C ++ 17的排序无济于事:调用(而非类成员访问)使用指向对象(to initialize this
)的指针,这违反了rules for out-of-lifetime pointers。
侧面说明:如果仅未定义一个顺序,则C ++ 17之前的“未指定顺序”也将是:如果未指定行为的any of the possibilities是未定义行为,则该行为是未定义的。 (您如何知道选择了定义明确的选项?未定义的选项可以模拟它,然后然后释放鼻恶魔。)
答案 2 :(得分:2)
您似乎假设a->f(0)
具有以下步骤(对于最新的C ++标准,按照该顺序,对于以前的版本按某种逻辑顺序):
*a
a->f
(所谓的绑定成员函数)0
a->f
上调用绑定成员函数(0)
但是a->f
没有值或类型。它本质上是一个 nonthing ,是一个无意义的语法元素,仅需 ,因为该语法会分解成员访问和函数调用,即使在成员函数调用中通过定义组合成员访问和函数调用。
因此,询问何时a->f
被“评估”是一个毫无意义的问题:对于a->f
无值,无类型的表达式,没有独特的评估步骤 strong>。
因此,基于这种对非实体评估顺序的讨论的任何推理也都是无效的。
编辑:
实际上,这比我写的要糟糕,表达式a->f
有一个假的“类型”:
E1.E2是“参数类型列表cv返回T的功能”。
“ parameter-type-list cv的功能”甚至都不是类之外的有效声明符:不能像全局声明那样将f() const
作为声明符:
int ::f() const; // meaningless
在类f() const
中并不表示“带有cv = const的parameter-type-list =()的函数”,而是表示(带有cv的parameter-type-list =()的成员函数) = const)。没有用于适当的“参数类型列表cv函数”的 proper 声明符。它只能存在于类中; 没有类型“参数类型函数” -list cv返回T“ ,可以声明它,也可以包含真正的可计算表达式。
答案 3 :(得分:-1)
除了别人说的话:
此程序存在内存泄漏,从技术上讲,它本身不是未定义的行为。
但是,如果您致电delete a;
来阻止它-这应该是未定义的行为,因为delete
将第二次致电a->~A()
[第12.4 / 14节]。
否则,实际上这是其他人建议的-编译器按照A* a = malloc(sizeof(A)); a->A(); a->~A(); a->f(0);
的方式生成机器代码。
由于没有成员变量或虚函数,所以所有三个成员函数均为空({return;}
),并且什么也不做。指针a
甚至仍指向有效内存。
它会运行,但调试器可能会抱怨内存泄漏。
但是,在f()
中使用任何非静态成员变量可能是未定义的行为,因为在它们被(隐式)编译器破坏之后,您正在访问 them -生成了~A()
。如果是std::string
或std::vector
之类的话,可能会导致运行时错误。
如果您将a->~A()
替换为调用delete a;
的表达式,那么我认为这将是未定义的行为,因为指针a
在那时不再有效。
尽管如此,由于函数f()
为空,因此代码仍应运行而不会出错。如果它访问任何成员变量,则可能由于a
的内存已释放而崩溃或导致了随机结果。
auto a = new A; new(a) A;
本身是未定义的行为,因为您为同一内存第二次调用A()
。
在那种情况下,单独调用f()是有效的,因为存在a
,但两次构造a
就是UB。
如果A
不包含任何带有构造函数来分配内存的对象,它将运行良好。否则可能导致内存泄漏等,但是f()可以很好地访问它们的“第二”副本。
答案 4 :(得分:-3)
我不是语言律师,但我接受了您的代码段并对其进行了一些修改。我不会在生产代码中使用它,但这似乎会产生有效的定义结果...
#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).
这一次我们不再更改类,但是之后让我们调用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->~A()
之后仍保持其值,因为在new
上调用了A
并且尚未调用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时,以上方法也没有考虑。
除编译器的语言设置外,还有很多其他因素,例如优化,约定调用和其他各种编译器标志。很难说,我没有完整的最新起草标准的副本,可以对此进行更深入的研究。也许这可以帮助您,能够更彻底地回答您问题的其他人,以及其他读者形象地表现这种行为。