首先,你(我必须说非常聪明)使用“placement new”作为实现赋值运算符
operator=()
的手段的整个动机,正如这个问题(
std::vector of objects and const-correctness)所引发的,现在已经无效了。从C++11开始,该问题的代码现在没有错误。请参见
my answer here。
其次,C++11的
emplace()
函数现在几乎完全做到了你使用
placement new所做的事情,只是它们现在由编译器本身保证是符合C++标准的良好定义行为。
第三,当
the accepted answer声明:
我想知道这是因为放置新的复制构造操作可能会更改
this
变量中包含的值,而不是因为使用该类实例的任何内容可能仍保留其旧实例数据的缓存值,而不是从内存中读取对象实例的新值。如果是前者,那么在赋值运算符函数中使用
this
指针的临时副本可以确保
this
正确,就像这样:
A& operator=(const A& right)
{
if (this == &right) return *this;
this->~A();
void * thisBak = this;
new (this) A(right);
return *thisBak;
}
但是,如果这与一个被缓存的对象有关,并且每次使用它时都不需要重新读取,我想知道
volatile
是否可以解决这个问题!例如:使用
volatile const int c;
作为类成员,而不是
const int c;
。
第四,我的回答的其余部分将重点关注
volatile
的用法,适用于类成员,以查看它是否可以解决这两种潜在的未定义行为情况中的第二个:
The potential UB in your own solution:
A& operator=(const A& right)
{
if (this == &right) return *this;
this->~A();
new (this) A(right);
return *this;
}
The potential UB you mention may exist in the other solution.
*const_cast<int*> (&c)= assign.c;
虽然我认为加上
volatile
可能会解决上述两种情况,但我在本回答的重点是上述第二种情况。
简而言之:
如果你添加
volatile
并将类成员变量
const int c;
改为
volatile const int c;
,那么这个(尤其是第二种情况)就成为了标准定义的有效行为。我不能说这是一个好主意,但我认为去掉
const
的强制转换并写入
c
就变成了定义明确的行为,完全有效。否则,行为仅因为
读取 c
可能被缓存和/或优化而导致未定义,因为它只是
const
,而不是
volatile
。
阅读以下内容以获取更多细节和理由,包括一些示例和少量汇编代码。
引用块:
常量成员和赋值运算符。如何避免未定义行为?
只有在写入 const
成员时才会产生未定义行为...
因为编译器可能会优化掉对变量的进一步读取,因为它是const。换句话说,即使您正确地更新了存储在内存中给定地址处的值,编译器也可能告诉代码仅重复最后一次读取的值,而不是每次从该变量读取时返回到内存地址并实际检查新值。
因此,这样做:
const int c;
*const_cast<int*>(&c) = assign.c;
“probably is”未定义行为。它可能在某些情况下有效,但在其他情况下无效,在某些编译器上有效,但在其他编译器版本中无效。我们不能依赖它具有可预测的行为,因为语言没有指定每次将变量设置为“const”,然后写入和读取时应该发生什么。
例如,这个程序(参见链接:
https://godbolt.org/z/EfPPba):
#include <cstdio>
int main() {
const int i = 5;
*(int*)(&i) = 8;
printf("%i\n", i);
return 0;
}
“prints 5(尽管我们希望它打印8),并在主函数中生成这个汇编代码。(注意,我不是汇编专家)。我已经标记了printf行。你可以看到,即使8被写入该位置(mov DWORD PTR [rax], 8),但printf行不会读取新值。它们读取以前存储的5,因为它们不期望其发生变化,即使它确实发生了变化。这种行为是未定义的,因此在这种情况下省略了读取。”
push rbp
mov rbp, rsp
sub rsp, 16
mov DWORD PTR [rbp-4], 5
lea rax, [rbp-4]
mov DWORD PTR [rax], 8
mov esi, 5
mov edi, OFFSET FLAT:.LC0
mov eax, 0
call printf
mov eax, 0
leave
ret
“写入
volatile const
变量并不是未定义的行为......”,因为
volatile
告诉编译器在每次读取该变量时都要在实际内存位置读取其内容,因为它可能随时改变!
你可能会想:“这有意义吗?”(拥有一个
volatile const
变量。我的意思是:“什么可能改变一个
const
变量,使我们需要将其标记为
volatile
?”)答案是:“嗯,是的!它确实有意义!”在微控制器和其他低级内存映射嵌入式设备上,一些寄存器
可能随时由底层硬件更改,是只读的。在C或C++中将它们标记为只读,我们使用
const
,但为了确保编译器知道
每次读取变量时都要实际读取它们地址位置上的内存,而不是依赖于保留先前缓存值的优化,我们还将它们标记为
volatile
。因此,要将地址
0xF000
标记为名为
REG1
的只读8位寄存器,我们可以在某个头文件中定义它如下:
#define REG1 (*(volatile const uint8_t*)(0xF000))
现在,我们可以随意地要求它阅读,
每次我们要求代码读取变量时,它都会这样做。 这是定义明确的行为。现在,我们可以做这样的事情,而且这段代码不会被优化掉,因为编译器知道这个寄存器的值
实际上可能随时改变,因为它是
volatile
。
while (REG1 == 0x12)
{
}
而且,当然,要将
REG2
标记为8位读/写寄存器,我们只需删除
const
。但是,在这两种情况下,都需要使用
volatile
,因为硬件随时可能更改这些变量的值,所以编译器最好不要对这些变量进行任何假设或尝试缓存它们的值并依赖于缓存读数。
#define REG2 (*(volatile uint8_t*)(0xF001))
因此,以下内容并不是未定义行为!就我所知,这是非常明确定义的行为:
volatile const int c;
*const_cast<int*>(&c) = assign.c;
即使变量是
const
,我们也可以强制转换掉
const
并写入它,编译器将尊重这一点并实际写入它。而且,现在变量也被标记为
volatile
,编译器将每次都读取它,并像读取上面的
REG1
或
REG2
一样尊重它。
因此,现在我们添加了
volatile
(在此处查看:
https://godbolt.org/z/6K8dcG)的程序:
#include <cstdio>
int main() {
volatile const int i = 5;
*(int*)(&i) = 8;
printf("%i\n", i);
return 0;
}
打印输出
8
,现在是正确的,并且在
main
中生成了这个汇编代码。再次,我标记了
printf
的行。请注意我标记的新的和不同的行!这些是汇编输出的
唯一更改!除此之外,其他每一行都完全相同。下面标记的新行会去
实际读取变量的新值并将其存储到寄存器
eax
中。接下来,在准备打印时,它不再像以前那样将硬编码的
5
移动到寄存器
esi
中,而是将刚刚读取的寄存器
eax
中的内容(现在包含一个
8
)移动到寄存器
esi
中。问题解决了!添加
volatile
修复了它!
push rbp
mov rbp, rsp
sub rsp, 16
mov DWORD PTR [rbp-4], 5
lea rax, [rbp-4]
mov DWORD PTR [rax], 8
mov eax, DWORD PTR [rbp-4]
mov esi, eax
mov edi, OFFSET FLAT:.LC0
mov eax, 0
call printf
mov eax, 0
leave
ret
这里有一个更大的演示(在线运行:
https://onlinegdb.com/HyU6fyCNv)。您可以看到,我们可以通过将其强制转换为非const引用或非const指针来写入变量。
在所有情况下(将其强制转换为非const引用或非const指针以修改const值),我们都可以使用C++风格的转换或C风格的转换。
在上面的简单示例中,我验证了在所有四种情况下(甚至使用C样式的转换将其转换为引用:
(int&)(i) = 8;
,奇怪的是,因为C没有引用:))汇编输出都是相同的。
#include <stdio.h>
int main()
{
printf("Hello World\n");
const int i1 = 5;
printf("%d\n", i1);
*const_cast<int*>(&i1) = 6;
printf("%d\n\n", i1);
volatile const int i2 = 5;
printf("%d\n", i2);
const_cast<int&>(i2) = 7;
printf("%d\n\n", i2);
volatile const int i3 = 5;
printf("%d\n", i3);
(int&)(i3) = 8;
printf("%d\n\n", i3);
volatile const int i4 = 5;
printf("%d\n", i4);
*(const_cast<int*>(&i4)) = 9;
printf("%d\n\n", i4);
volatile const int i5 = 5;
printf("%d\n", i5);
*(int*)(&i5) = 10;
printf("%d\n\n", i5);
return 0;
}
样例输出:
Hello World
5
5
5
7
5
8
5
9
5
10
注释:
- 我还注意到,即使它们不是
volatile
,修改const
类成员时,上述方法也适用。请参阅我的“std_optional_copy_test”程序!例如:https://onlinegdb.com/HkyNyTt4D。然而,这可能是未定义的行为。为了使其定义明确,请将成员变量设置为volatile const
而不仅仅是const
。
- 之所以不必将
volatile const int
强制转换为volatile int
(即为什么仅使用int
引用或int
指针就可以正常工作),是因为volatile
影响变量的读取,而不是变量的写入。因此,只要我们通过volatile
变量手段读取变量,我们的读取就保证不会被优化掉。这就是给我们定义明确的行为的原因。写入总是有效的——即使变量不是volatile
。
参考资料:
- [我的回答] "placement new" 有哪些用途?
- x86 汇编指南
- 如何将对象的 "this" 指针指向不同的对象
- 来自 godbolt.org 的编译器 Explorer 输出和汇编代码:
- 这里:https://godbolt.org/z/EfPPba
- 还有这里:https://godbolt.org/z/6K8dcG
- [我的回答] 在 STM32 微控制器上进行寄存器级 GPIO 访问:类似 STM8(寄存器级 GPIO)的 STM32 编程
~A()
不是虚函数。没看到吗? - Alexey Malistov