常量成员和赋值运算符。如何避免未定义的行为?

42

回答了关于std::vector of objects and const-correctness的问题,并收到了一条关于未定义行为的评论。我不同意,因此我有一个问题。

考虑具有const成员的类:

class A { 
public: 
    const int c; // must not be modified! 
    A(int c) : c(c) {} 
    A(const A& copy) : c(copy.c) { }     
    // No assignment operator
}; 

我希望有一个赋值运算符,但我不想像下面一篇答案中的代码那样使用const_cast
A& operator=(const A& assign) 
{ 
    *const_cast<int*> (&c)= assign.c;  // very very bad, IMHO, it is undefined behavior
    return *this; 
} 

我的解决方案是

// Custom-defined assignment operator
A& operator=(const A& right)  
{  
    if (this == &right) return *this;  

    // manually call the destructor of the old left-side object
    // (`this`) in the assignment operation to clean it up
    this->~A(); 
    // use "placement new" syntax to copy-construct a new `A` 
    // object from `right` into left (at address `this`)
    new (this) A(right); 
    return *this;  
}  

我是否存在未定义的行为(UB)?

如果没有UB,有什么解决方案?


5
你的解决方案看起来非常丑陋并且对我来说很危险。 - Stephane Rolland
是的,请查看Roger Pate在您的答案中的评论。您可能正在调用基类构造函数,而这可能是一个派生对象。 - Conspicuous Compiler
1
@Stephane Rolland。这可能是你看到的,但未定义的行为呢? - Alexey Malistov
@引人注目的编译器。请看一下我在罗杰的评论中的回复。我的运算符只替换基本部分,而不是派生类。 - Alexey Malistov
@Alexey:嗯,你似乎没有理解这个问题。可能会有一个从A派生的类,析构函数应该始终被视为虚拟的。 - Conspicuous Compiler
@引人注目的编译器。~A() 不是虚函数。没看到吗? - Alexey Malistov
8个回答

48

您的代码会导致未定义行为。

不仅仅是"A被用作基类时,this、that或其他情况下是未定义的"。实际上是完全未定义的,总是如此。因为 this 不能保证引用新对象,所以 return *this 已经产生了UB(未定义行为)。

具体来说,考虑3.8/7:

如果在对象的生命周期结束后,在重新使用或释放原始对象占用的存储之前,在原始对象占用的存储位置创建一个新对象,则指向原始对象的指针,引用原始对象的引用或原始对象的名称将自动引用新对象,并且一旦新对象的生命周期开始,就可以用于操纵新对象,如果:

...

— 原始对象的类型没有const限定符,如果是类类型,则不包含任何类型为const限定符或引用类型的非静态数据成员,

现在,“在对象的生命周期结束后,在重新使用或释放原始对象占用的存储之前,在原始对象占用的存储位置创建一个新对象”正是您正在做的事情。

您的对象是类类型,并且确实包含一个带有const限定符的非静态数据成员。因此,在运行赋值运算符后,指向旧对象的指针、引用和名称不保证引用新对象并可用于操纵它。

作为一个可能出错的具体示例,请考虑:

A x(1);
B y(2);
std::cout << x.c << "\n";
x = y;
std::cout << x.c << "\n";

期望得到这个输出结果吗?

1
2

错误!你可能会得到那个输出,但是const成员是3.8/7规则中的例外情况的原因是编译器可以将x.c看作它声称的const对象。换句话说,编译器被允许将此代码视为:

A x(1);
B y(2);
int tmp = x.c
std::cout << tmp << "\n";
x = y;
std::cout << tmp << "\n";

因为(非正式地说)常量对象不会改变它们的值,所以当优化涉及到常量对象的代码时,这种保证的潜在价值就显而易见了。如果要修改x.c不引发UB,则必须删除这个保证。因此,只要标准的编写者没有犯错误,就没有办法做到你想做的。

[*] 实际上,我对使用this作为放置new的参数有疑虑——可能你应该先将其复制到void*中,然后再使用它。但是,我并不关心具体的东西是否UB,因为这不会挽救整个函数。


3
不错的发现。我认为比@sbi的回答更好。+1 :) - Prasoon Saurav
1
std::launder 可以用来避免未定义行为吗? - Indiana Kernick
如果标准允许结构体拥有const成员,那么除了“这个对象只能通过覆盖父结构来改变,这可能导致任何现有指向该对象成员的指针或引用的值变得不确定”之外,还可能有什么合理的意义呢?标准的作者们认为编译器编写者不需要被喂饱每一个细节,告诉他们在某些情况下应该如何处理每一个角落案例,其中一种操作是有用的,而其他任何操作都没有意义。 - supercat
1
@Bernd 最近的回答似乎是今天的正确答案:https://dev59.com/2m855IYBdhLWcg3wz3vo#63489092。 - Gabriel Staples

28

首先,当你将一个数据成员声明为const时,你告诉编译器和全世界这个数据成员永远不会改变。当然,你不能对其进行赋值,而且你一定不能欺骗编译器接受那些试图改变它的代码,无论这些代码多么巧妙。
你可以拥有一个const数据成员或者一个赋值运算符为所有数据成员赋值。 两者都不能同时存在。

关于你的“解决方案”:
我认为在一个成员函数中调用对象的析构函数会立即导致未定义行为(UB)。 在已经为对象占用了空间的位置上调用未初始化原始数据的构造函数以创建对象,也非常像UB(该操作简直令人毛骨悚然)。我没有标准文件证明这一点。我讨厌阅读标准文档,我认为它的格律难听。

然而,抛开技术细节不谈,我承认只要代码像你的示例那样简单,你可能可以在几乎所有平台上使用你的“解决方案”。但这并不意味着它是一个好的解决方案。事实上,我认为它甚至都不是一个可接受的解决方案,因为根据我的经验,代码永远不会像那样简单。多年以后,它将被扩展、更改、变异和扭曲,然后它将默默地失败,并需要进行令人昏昏欲睡的36小时调试才能找到问题。我不知道你的想法,但每当我发现一个像这样的代码,花费我36小时调试时,我都想勒死那个可怜的蠢货谁这么对待我。

在他的GotW #23中,Herb Sutter逐一分析了这个想法,最终得出结论:“它充满陷阱,它常常是错误的,并且它使派生类的作者的生活变成了地狱......永远不要使用将复制赋值实现为复制构造的技巧,即使用显式析构函数后跟定位new,尽管这种技巧每三个月会在新闻组中出现一次” (强调我的)。


4
“@Alexey: <耸肩> 你或许现在就想要一千万美元,‘没有任何争论’。可是你仍然得不到它。” - sbi
4
我希望您能每天午餐时免费提供蛋糕,但这是不可能的。您想要的与C ++根本不兼容。也许您应该退一步 - 显然,您正在创建的类本身不是“常量”,因为可以修改实例,并且“c”字段不是“const”,因为修改实例会修改“c”。 因此,“c”不应标记为“const”。将其设置为非“const”并将其设置为“private”,然后添加一个成员函数“int getFoo() const”,该函数返回该值,而不是试图通过转圈来做C++和基本逻辑认为是荒谬的事情。 - Jonathan Grynspan
3
@Alexey,不清楚你为什么想要改变一些你明确告诉编译器“永远不会改变”的东西。 - Mark B
2
+1 给 GotW 链接。我认为这本身就说明了为什么你的“解决方案”值得被踩。 - Roddy
3
@sbi说: "我没有那个标准的具体章节和条款" - 实际上,如果不考虑const数据成员,我认为这将是定义良好的行为。也许这是一个糟糕的设计,因为像Herb Sutter和其他人提出的所有原因,但据我所知,只要它仅用于动态类型为A的对象,它就是定义良好的。这是基于我的答案中的相关章节和条款。 - Steve Jessop
显示剩余7条评论

11
如何在 A 有 const 成员的情况下进行赋值?你试图实现一些基本上不可能的东西。你的解决方案与原始方案没有新行为,这并不一定是未定义行为(UB),但是你的确存在 UB。
事实很简单,你正在更改一个 const 成员。你需要取消成员的 const 属性,或者放弃赋值运算符。你的问题没有解决方法-它是完全矛盾的。
编辑以获得更多清晰度:
const 强制类型转换并不总是会引入未定义行为。然而,你肯定已经存在了 UB。除此之外,如果不确定 T 是否为 POD 类,则在放入中之前不调用所有析构函数是未定义的,而你甚至没有调用正确的析构函数。此外,各种形式的继承也涉及 owch-time 未定义行为。
你确实触发了未定义行为,而通过“不尝试对常量对象进行赋值”可以避免这种情况。

1
我希望vector<A>::push_back(a)能够正常工作。很明显,赋值运算符必须用新数据替换所有成员数据。 - Alexey Malistov
我的问题是"我是否存在未定义行为?"以及"如何避免UB?" 你的答案在哪里? - Alexey Malistov
2
@Alexey:你的代码中存在大量未定义行为,如果不尝试给一个const对象赋值,就可以避免这种情况。 - Puppy

3
首先,你(我必须说非常聪明)使用“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正确,就像这样:
// Custom-defined assignment operator
A& operator=(const A& right)  
{  
    if (this == &right) return *this;  

    // manually call the destructor of the old left-side object
    // (`this`) in the assignment operation to clean it up
    this->~A(); 

    // Now back up `this` in case it gets corrupted inside this function call
    // only during the placement new copy-construction operation which 
    // overwrites this objct:
    void * thisBak = this;

    // use "placement new" syntax to copy-construct a new `A` 
    // object from `right` into left (at address `this`)
    new (this) A(right); 

    // Note: we cannot write to or re-assign `this`. 
    // See here: https://dev59.com/HGMl5IYBdhLWcg3wqIdh#18227566

    // Return using our backup copy of `this` now
    return *thisBak;  
}  

但是,如果这与一个被缓存的对象有关,并且每次使用它时都不需要重新读取,我想知道volatile是否可以解决这个问题!例如:使用volatile const int c;作为类成员,而不是const int c;
第四,我的回答的其余部分将重点关注volatile的用法,适用于类成员,以查看它是否可以解决这两种潜在的未定义行为情况中的第二个:
  1. The potential UB in your own solution:

     // Custom-defined assignment operator
     A& operator=(const A& right)  
     {  
         if (this == &right) return *this;  
    
         // manually call the destructor of the old left-side object
         // (`this`) in the assignment operation to clean it up
         this->~A(); 
         // use "placement new" syntax to copy-construct a new `A` 
         // object from `right` into left (at address `this`)
         new (this) A(right); 
         return *this;  
     }  
    
  2. The potential UB you mention may exist in the other solution.

     // (your words, not mine): "very very bad, IMHO, it is 
     // undefined behavior"
     *const_cast<int*> (&c)= assign.c;
    
虽然我认为加上 volatile 可能会解决上述两种情况,但我在本回答的重点是上述第二种情况。
简而言之:
如果你添加 volatile 并将类成员变量 const int c; 改为 volatile const int c;,那么这个(尤其是第二种情况)就成为了标准定义的有效行为。我不能说这是一个好主意,但我认为去掉 const 的强制转换并写入 c 就变成了定义明确的行为,完全有效。否则,行为仅因为 读取 c 可能被缓存和/或优化而导致未定义,因为它只是 const,而不是 volatile
阅读以下内容以获取更多细节和理由,包括一些示例和少量汇编代码。
引用块:
常量成员和赋值运算符。如何避免未定义行为?

只有在写入 const 成员时才会产生未定义行为...

因为编译器可能会优化掉对变量的进一步读取,因为它是const。换句话说,即使您正确地更新了存储在内存中给定地址处的值,编译器也可能告诉代码仅重复最后一次读取的值,而不是每次从该变量读取时返回到内存地址并实际检查新值。
因此,这样做:
// class member variable:
const int c;    

// anywhere
*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

// printf lines
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 a read-only 8-bit register
#define REG1 (*(volatile const uint8_t*)(0xF000))

现在,我们可以随意地要求它阅读,每次我们要求代码读取变量时,它都会这样做。 这是定义明确的行为。现在,我们可以做这样的事情,而且这段代码不会被优化掉,因为编译器知道这个寄存器的值实际上可能随时改变,因为它是volatile
while (REG1 == 0x12)
{
    // busy wait until REG1 gets changed to a new value
}

而且,当然,要将REG2标记为8位读/写寄存器,我们只需删除const。但是,在这两种情况下,都需要使用volatile,因为硬件随时可能更改这些变量的值,所以编译器最好不要对这些变量进行任何假设或尝试缓存它们的值并依赖于缓存读数。
// define a read/write 8-bit register
#define REG2 (*(volatile uint8_t*)(0xF001))

因此,以下内容并不是未定义行为!就我所知,这是非常明确定义的行为:
// class member variable:
volatile const int c;    

// anywhere
*const_cast<int*>(&c) = assign.c;

即使变量是const,我们也可以强制转换掉const并写入它,编译器将尊重这一点并实际写入它。而且,现在变量也被标记为volatile,编译器将每次都读取它,并像读取上面的REG1REG2一样尊重它。
因此,现在我们添加了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

// printf lines
mov     eax, DWORD PTR [rbp-4]  // NEW!
mov     esi, eax                // DIFFERENT! Was `mov     esi, 5`
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");

    // This does NOT work!
    const int i1 = 5;
    printf("%d\n", i1);
    *const_cast<int*>(&i1) = 6;
    printf("%d\n\n", i1); // output is 5, when we want it to be 6!
    
    // BUT, if you make the `const` variable also `volatile`, then it *does* work! (just like we do
    // for writing to microcontroller registers--making them `volatile` too). The compiler is making
    // assumptions about that memory address when we make it just `const`, but once you make it
    // `volatile const`, those assumptions go away and it has to actually read that memory address
    // each time you ask it for the value of `i`, since `volatile` tells it that the value at that
    // address could change at any time, thereby making this work.

    // Reference casting: WORKS! (since the `const` variable is now `volatile` too)

    volatile const int i2 = 5;
    printf("%d\n", i2);
    const_cast<int&>(i2) = 7;
    // So, the output of this is 7:
    printf("%d\n\n", i2);
    
    // C-style reference cast (oddly enough, since C doesn't have references :))
    
    volatile const int i3 = 5;
    printf("%d\n", i3);
    (int&)(i3) = 8;
    printf("%d\n\n", i3);
    

    // It works just fine with pointer casting too instead of reference casting, ex:
    
    volatile const int i4 = 5;
    printf("%d\n", i4);
    *(const_cast<int*>(&i4)) = 9;
    printf("%d\n\n", i4);

    // or C-style:
    
    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

注释:
  1. 我还注意到,即使它们不是volatile,修改const类成员时,上述方法也适用。请参阅我的“std_optional_copy_test”程序!例如:https://onlinegdb.com/HkyNyTt4D。然而,这可能是未定义的行为。为了使其定义明确,请将成员变量设置为volatile const而不仅仅是const
  2. 之所以不必将volatile const int强制转换为volatile int(即为什么仅使用int引用或int指针就可以正常工作),是因为volatile影响变量的读取,而不是变量的写入。因此,只要我们通过volatile变量手段读取变量,我们的读取就保证不会被优化掉。这就是给我们定义明确的行为的原因。写入总是有效的——即使变量不是volatile

参考资料:

  1. [我的回答] "placement new" 有哪些用途?
  2. x86 汇编指南
  3. 如何将对象的 "this" 指针指向不同的对象
  4. 来自 godbolt.org 的编译器 Explorer 输出和汇编代码:
    1. 这里:https://godbolt.org/z/EfPPba
    2. 还有这里:https://godbolt.org/z/6K8dcG
  5. [我的回答] 在 STM32 微控制器上进行寄存器级 GPIO 访问:类似 STM8(寄存器级 GPIO)的 STM32 编程

2

如果你想要一个不可变的(但可分配的)成员,那么在没有未定义行为的情况下,你可以按照以下方式布局:

#include <iostream>

class ConstC
{
    int c;
protected:
    ConstC(int n): c(n) {}
    int get() const { return c; }
};

class A: private ConstC
{
public:
    A(int n): ConstC(n) {}
    friend std::ostream& operator<< (std::ostream& os, const A& a)
    {
        return os << a.get();
    }
};

int main()
{
    A first(10);
    A second(20);
    std::cout << first << ' ' << second << '\n';
    first = second;
    std::cout << first << ' ' << second << '\n';
}

2
根据更新的C++标准草案版本N4861,似乎不再存在未定义行为 (链接)
如果在对象的生命周期结束后,在重新使用或释放该对象所占用的存储空间之前,一个新的对象被创建在原来对象所占用的存储位置上,那么指向原对象的指针、引用或原对象的名称将自动指向新对象,并且一旦新对象的生命周期开始,如果原对象是可以透明替换的(见下文),则可以使用它们来操作新对象。如果对象o1可以透明地被对象o2替换,则对象o1是透明可替换的,具体条件如下:
- o2所占用的存储空间完全覆盖了o1所占用的存储空间; - o1和o2是相同类型的(忽略顶层CV限定符); - o1不是完全const对象; - o1和o2都不是潜在重叠子对象([intro.object]); - 要么o1和o2都是完整对象,要么o1和o2分别是p1和p2的直接子对象,其中p1可以透明替换为p2。
在这里,您只能找到关于常量的“o1不是完整的const对象”,在这种情况下是正确的。但是当然,您还必须确保没有违反所有其他条件。

1

如果没有其他(非const)成员,无论是否存在未定义的行为,这都毫无意义。

A& operator=(const A& assign) 
{ 
    *const_cast<int*> (&c)= assign.c;  // very very bad, IMHO, it is UB
    return *this; 
}

据我所知,这里没有未定义的行为发生,因为c不是一个static const实例,否则你将无法调用复制赋值运算符。然而,const_cast应该提醒你,告诉你有些事情是错误的。 const_cast主要设计用于解决非const正确的API问题,但在这里似乎并不是这种情况。
此外,在以下代码片段中:
A& operator=(const A& right)  
{  
    if (this == &right) return *this;  
    this->~A() 
    new (this) A(right); 
    return *this;  
}

您面临两个主要风险,其中第一个已经被指出。

  1. 在存在派生自A的实例和虚析构函数时,这将导致原始实例仅部分重建。
  2. 如果new(this) A(right);中的构造函数调用引发异常,则会使您的对象被销毁两次。在这种特殊情况下,这不是问题,但如果您需要进行重要的清理工作,那么您将会后悔。

编辑:如果您的类具有此const成员,该成员在您的对象中不被视为“状态”(即它是用于跟踪实例的某种ID,并且不是operator==等比较的一部分),则以下内容可能是有意义的:

A& operator=(const A& assign) 
{ 
    // Copy all but `const` member `c`.
    // ...

    return *this;
}

2
你的第一个例子实际上是UB,因为c是一个真正的const项。 - Mark B
如果A实例在某个只读存储位置中被找到,那么这难道不仅是未定义行为吗? - André Caron
不,总是UB。请参考Steve Jessop的答案。 - Cheers and hth. - Alf

0

请阅读此链接:

http://www.informit.com/guides/content.aspx?g=cplusplus&seqNum=368

具体来说...

据称,这个技巧可以防止代码重复。然而,它有一些严重的缺陷。为了使其工作,C++的析构函数必须将已删除的每个指针赋值为NULL,因为后续的复制构造函数调用可能会再次删除相同的指针,当重新分配新值给char数组时。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接