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

时间:2010-11-09 16:42:45

标签: c++ const undefined-behavior assignment-operator

answered关于std::vector of objects and const-correctness的问题,并且不值得 downvote和关于未定义行为的评论。我不同意,因此我有一个问题。

考虑具有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 UB
    return *this; 
} 

我的解决方案是

A& operator=(const A& right)  
{  
    if (this == &right) return *this;  
    this->~A() 
    new (this) A(right); 
    return *this;  
}  

我是否有未定义的行为?

请你的解决方案没有UB。

8 个答案:

答案 0 :(得分:37)

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

不仅仅是“未定义如果A被用作基类而且这个,那个或那个”。实际上是未定义的,总是如此。 return *this已经是UB,因为this无法保证引用新对象。

具体来说,考虑3.8 / 7:

  

如果,在一个物体的生命周期之后   已经结束,在存储之前   被占用的对象被重用或   发布后,创建了一个新对象   存储位置   原始对象占用,一个指针   指向原始对象,a   参考提到的   原始对象,或者名称   原始对象将自动   引用新对象,一旦引用   新对象的生命周期   开始,可以用来操纵   新对象,如果:

     

...

     

- 原始对象的类型是   不是const限定的,如果是一个类   类型,不包含任何非静态   数据成员的类型是   const限定或引用类型,

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

您的对象属于类类型, 包含非静态数据成员,其类型为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";

因为(非正式地) const对象不会改变它们的值。优化涉及const对象的代码时,此保证的潜在价值应该是显而易见的。为了能够在没有调用UB的情况下修改x.c 的任何方法,必须删除此保证。因此,只要标准作者完成了他们的工作而没有错误,就没有办法做你想做的事。

[*]实际上我怀疑使用this作为放置新的参数 - 可能你应该首先将它复制到void*并使用它。但是我并不担心这是否特别是UB,因为它不能保存整个功能。

答案 1 :(得分:22)

首先:当您创建数据成员const时,您告诉编译器和全世界 此数据成员永远不会更改 。当然, 你无法分配 ,你当然 绝不能欺骗 编译器接受这样做的代码,无论诡计多么聪明 您可以将const数据成员分配给所有数据成员。 你不能同时拥有两者。

至于你对问题的“解决方案”:
我认为在为该对象调用的成员函数中的对象上调用析构函数会立即调用 UB 在未初始化的原始数据上调用构造函数,以从成员函数中创建一个对象,该成员函数已被调用用于现在在原始数据上调用构造函数的对象 ...也非常听起来像 UB 对我来说。 (见鬼,只是把它拼出来让我的脚趾甲卷曲。)而且,不,我没有标准的章节和诗句。我讨厌阅读标准。我想我无法忍受它的表。

然而,除了技术性之外,我承认,只要代码保持与示例中一样简单 上使用“解决方案” >。尽管如此,这还不能成为好的解决方案。事实上,我认为它甚至不是可接受的解决方案,因为IME代码永远不会那么简单。多年来它将被扩展,改变,变异和扭曲,然后它将默默地失败,并且需要麻烦的36小时调试转移以找到问题。我不了解你,但每当我发现这样的一段代码负责36小时的调试乐趣时,我想扼杀那个为我做这件事的愚蠢的傻瓜。

Herb Sutter在他的GotW #23中,一个接一个地剖析了这个想法,最后得出的结论是“ 充满了陷阱 ,它是 < em>经常出错 让派生类作者的生活变得生动地狱 ...... 永远不会使用这个技巧通过使用显式析构函数然后放置新的来实现复制构造方面的复制赋值,即使这个技巧每三个月在新闻组中出现一次“(强调我的)。

答案 2 :(得分:9)

如果它有一个const成员,你如何分配给A?你正试图完成一些根本不可能的事情。你的解决方案没有新的行为,不一定是UB,但你肯定是。

简单的事实是,你正在改变一个const成员。你需要取消对你的成员的约束,或者抛弃赋值运算符。你的问题没有解决办法 - 这是完全矛盾的。

编辑以获得更清晰:

Const cast并不总是引入未定义的行为。但是,你肯定做到了。除了你确定T是一个POD类之外,在你放入它之前不会调用所有析构函数并且你甚至没有调用正确的析构函数是不明确的。此外,还有各种形式的继承所涉及的未定义行为。

您可以调用未定义的行为,并且可以通过不尝试分配给const对象来避免这种情况。

答案 3 :(得分:1)

如果你肯定想拥有一个不可变的(但是可指定的)成员,那么没有UB就可以这样做:

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

答案 4 :(得分:1)

根据较新的C ++标准草案版本N4861,它似乎不再是未定义的行为(link)

如果在对象的生存期结束之后并且在存储该对象之前 占用被重用或释放,在存储位置创建一个新对象 原始对象被占用,指向原始对象的指针,引用原始对象的引用或原始对象的名称 自动引用新对象,并且在新对象的生存期开始后,如果原始对象可以被新对象透明地替换(请参见下文),则可以使用该对象来操作新对象。 如果满足以下条件,则对象o1可以透明地替换为对象o2:

  • o2占用的存储空间正好覆盖了o1占用的存储空间,并且
  • o1和o2具有相同的类型(忽略顶级cv限定词),并且
  • o1不是完整的const对象,并且
  • o1和o2都不是潜在重叠的子对象([intro.object]),并且
  • o1和o2都是完整对象,或者o1和o2分别是对象p1和p2的直接子对象,并且p1可以透明地替换为p2。

在这里您只能找到有关const的“ o1不是完整的const对象”,在这种情况下是正确的。但是,当然,您必须确保不违反所有其他条件。

答案 5 :(得分:0)

阅读此链接:

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

特别是......

  

这个技巧涉嫌阻止代码   重叠式。但是,它有一些   严重的缺陷。为了工作,C   析构函数必须每次都指定NULLify   它已删除的指针因为   后续的复制构造函数调用   可能会再次删除相同的指针   当它为char重新分配一个新值时   阵列。

答案 6 :(得分:0)

如果没有其他(非const)成员,这根本没有任何意义,无论是否有未定义的行为。

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

AFAIK,这里没有发生任何未定义的行为,因为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 的实例和虚拟析构函数,这将导致仅部分重建原始实例。< / LI>
  2. 如果new(this) A(right);中的构造函数调用抛出异常,则对象将被销毁两次。在这种特殊情况下,这不会是一个问题,但如果你碰巧有大量的清理工作,你会后悔的。
  3. 修改:如果您的类中有const成员在您的对象中不被视为“状态”(即它是用于跟踪实例的某种ID,并且不属于在operator==之类的比较中,以下可能有意义:

    A& operator=(const A& assign) 
    { 
        // Copy all but `const` member `c`.
        // ...
    
        return *this;
    }
    

答案 7 :(得分:0)

首先,这是您(相当新颖)使用“ 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指针的临时副本来确保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://stackoverflow.com/a/18227566/4561887

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

但是,如果它与正在缓存的对象有关,并且每次使用时都没有重新读取,我想知道volatile是否可以解决这个问题!即:使用volatile const int c;作为类成员,而不是const int c;

第四,在剩下的答案中,我着重于volatile的用法(应用于类成员),以了解这是否可以解决这两种潜在的未定义行为案例中的第二种: / strong>

  1. 您自己的解决方案中的潜在UB:

     // 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. 您提到的潜在UB可能存在于the other solution中。

     // (your words, not mine): "very very bad, IMHO, it is 
     // undefined behavior"
     *const_cast<int*> (&c)= assign.c;
    

尽管我认为也许添加volatile可能会解决上述两种情况,但我在其余答案中的重点是上述第二种情况。

tldr;

在我看来,如果添加volatile并使类成员变量volatile const int c;成为标准,则这(尤其是上面的第二种情况)将成为有效且定义明确的行为而不只是const int c;。我不能说这是个好主意,但我认为抛弃const并写入c会成为定义明确的行为,并且非常有效。否则,该行为是不确定的,仅因为c reads 可能被缓存和/或优化了,因为它只是const,而不是volatile。 / em>

阅读以下内容以获得更多详细信息和理由,包括看一些示例和一些汇编。

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

写给const成员只是未定义的行为...

...因为编译器可能会进一步优化对该变量的读取,因为它是const。换句话说,即使您已经正确更新了内存中给定地址上包含的值,编译器也可能会告诉代码仅重新保存持有其初次读取的值的寄存器中的最后内容,而不是返回到内存中地址,并在每次您从该变量读取时实际检查新值。

所以这个:

// class member variable:
const int c;    

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

可能未定义的行为。它可能在某些情况下但在其他情况下不起作用,在某些编译器上但在其他情况下不起作用,或者在某些版本的编译器上但在其他情况下不起作用。我们不能依靠它具有可预测的行为,因为该语言没有指定每次我们将变量设置为const然后对其进行读写操作时应该发生的事情。

例如,该程序(请参见此处:https://godbolt.org/z/EfPPba):

#include <cstdio>
int main() {
  const int i = 5;
  *(int*)(&i) = 8;
  printf("%i\n", i);
  return 0;
}

打印5(尽管我们希望它打印8)并在main中生成此程序集。 (请注意,我不是汇编专家)。我已经标记了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:< / p>

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))

因此,以下是 not 未定义的行为!据我所知,这是一个非常明确的行为:

// 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行。注意我也标记了新行和不同行!这些是对程序集输出的 only 更改!每隔一行完全相同。标记在下面的新行消失,实际上读取变量的新值并将其存储到寄存器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指针),我们可以使用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. 我还注意到,即使修改const类成员,即使它们不是volatile,以上方法也可以工作。参见我的“ std_optional_copy_test”程序!例如:https://onlinegdb.com/HkyNyTt4D。但是,这可能是未定义的行为。要使其定义明确,请使成员变量volatile const而不只是const
  2. 您不必从volatile const int强制转换为volatile int的原因(即:为什么只引用int引用或int指针)的原因很好,是因为volatile影响变量的 reading ,而不影响变量的写入。因此,只要我们通过易失性变量方法读取,我们就可以保证不会对读取进行优化。这就是给我们明确定义的行为的原因。即使变量不是volatile,写操作也始终有效。

引用:

  1. [我自己的答案] What uses are there for "placement new"?
  2. x86 Assembly Guide
  3. Change 'this' pointer of an object to point different object
  4. 编译器资源管理器从godbolt.org输出并进行汇编:
    1. 这里:https://godbolt.org/z/EfPPba
    2. 这里:https://godbolt.org/z/6K8dcG
  5. [我的回答]在STM32微控制器上的寄存器级GPIO访问:Programing STM32 like STM8(register level GPIO )