37

回答了关于std::vector of objects 和 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 会有什么解决方案?

4

8 回答 8

43

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

不仅仅是“如果 A 用作基类和这个、那个或另一个,则未定义”。实际上未定义,总是。return *this已经是UB,因为this不能保证引用新对象。

具体来说,考虑 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";

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

[*] 事实上,我对使用this作为放置新的参数有疑问 - 可能你应该将它复制到void*第一个,并使用它。但我并不担心这是否具体是 UB,因为它不会将函数作为一个整体保存。

于 2010-11-09T17:31:38.603 回答
25

第一:当你创建一个数据成员const时,你是在告诉编译器和全世界这个数据成员永远不会改变。当然,你不能分配给它,你当然不能欺骗编译器接受这样做的代码,无论这个技巧多么聪明。
您可以让一个const数据成员一个赋值运算符分配给所有数据成员。你不能两者兼得。

至于您对问题的“解决方案”:
我想在为该对象调用的成员函数中调用对象的析构函数将立即调用UB在未初始化的原始数据上调用构造函数以从成员函数中创建对象,该成员函数已为驻留的对象调用,该对象现在在原始数据上调用构造函数......对我来说听起来也很像UB。(见鬼,只是把这个拼出来让我的脚趾甲卷曲。)而且,不,我没有标准的章节和经文。我讨厌阅读标准。我觉得我受不了它的米。

但是,撇开技术问题不谈,我承认只要代码保持与您的示例一样简单,您几乎可以在每个平台上使用您的“解决方案” 。尽管如此,这并不能使它成为一个好的解决方案。事实上,我认为这甚至不是一个可接受的解决方案,因为 IME 代码从来没有这么简单。多年来,它会被扩展、改变、变异和扭曲,然后它会默默地失败,并且需要 36 小时的令人麻木的调试轮班才能找到问题。我不了解你,但每当我发现这样一段代码负责 36 小时的调试乐趣时,我都想扼杀对我这样做的可悲的愚蠢。

Herb Sutter 在他的GotW #23中逐条剖析了这个想法,最后得出结论,它“充满了陷阱经常是错误的,它让派生类的作者的生活变成了人间地狱......永远不要使用这个把戏通过使用显式析构函数后跟放置 new 来在复制构造方面实现复制分配,即使这个技巧每三个月就会在新闻组上出现”(强调我的)。

于 2010-11-09T16:53:47.633 回答
10

如果 A 有一个 const 成员,你怎么可能分配给它?你正在尝试完成一些根本不可能的事情。您的解决方案与原始解决方案相比没有新的行为,这不一定是 UB,但您的解决方案绝对是。

一个简单的事实是,您正在更改一个 const 成员。您要么需要取消成员的约束,要么放弃赋值运算符。您的问题没有解决方案 - 这是一个完全矛盾的问题。

编辑更清楚:

常量转换并不总是引入未定义的行为。然而,你肯定做到了。除此之外,除非您确定 T 是 POD 类,否则在放入它之前不调用所有析构函数是未定义的——而且你甚至没有调用正确的析构函数。此外,还有涉及各种继承形式的 owch-time 未定义行为。

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

于 2010-11-09T16:50:39.473 回答
2

如果您确实想要一个不可变(但可分配)的成员,那么如果没有 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';
}
于 2010-11-09T17:56:13.540 回答
2

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

如果在对象的生命周期结束之后,在对象占用的存储空间被重用或释放之前,在原始对象占用的存储位置创建一个新对象,一个指向原始对象的指针,一个指向原始对象的引用引用原始对象,或者原始对象的名称将自动引用新对象,并且一旦新对象的生命周期开始,可以用于操作新对象,如果原始对象是透明可替换的(参见下面)由新对象。如果满足以下条件,则对象 o1 可以透明地被对象 o2 替换:

  • o2 占用的存储空间正好覆盖 o1 占用的存储空间,并且
  • o1 和 o2 属于同一类型(忽略顶级 cv 限定符),并且
  • o1 不是一个完整的 const 对象,并且
  • o1 和 o2 都不是可能重叠的子对象([intro.object]),并且
  • 要么 o1 和 o2 都是完整的对象,要么 o1 和 o2 分别是对象 p1 和 p2 的直接子对象,并且 p1 可以被 p2 透明地替换。

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

于 2020-08-19T14:22:20.073 回答
1

在没有其他(非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 虚拟析构函数的情况下,这将导致仅对原始实例进行部分重构。
  2. 如果构造函数调用new(this) A(right);抛出异常,您的对象将被销毁两次。在这种特殊情况下,这不是问题,但如果您碰巧进行了重大清理,您会后悔的。

编辑:如果你的类有这个const在你的对象中不被视为“状态”的成员(即,它是用于跟踪实例的某种 ID,而不是比较operator==等的一部分),那么以下可能是有意义的:

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

    return *this;
}
于 2010-11-09T17:04:38.557 回答
1

首先,正如这个问题(对象的 std::vector 和 const-correctnessoperator=()所引发的那样,您(我可能会说非常巧妙)使用“placement new”作为实现赋值运算符的手段的全部动机是现在作废了。从 C++11 开始,该问题的代码现在没有错误。在这里查看我的答案

其次, C++11 的emplace()函数现在几乎完全按照您使用placement new所做的工作,除了现在编译器本身几乎保证它们都是按照C++ 标准定义良好的行为。

第三,接受的答案指出:

因为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应用于类成员的 的用法上,看看这是否可以解决这两个潜在的未定义行为案例中的第二个:

  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 可能存在于其他解决方案中

     // (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会被缓存和/或优化,因为它只是const,而不是volatile

阅读以下内容以了解更多详细信息和理由,包括查看一些示例和一些组装。

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为一个名为的只读 8 位寄存器REG1,我们会在某处的头文件中这样定义它:

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

prints 8,现在是正确的,并在main. 我再次标记了这些printf线条。请注意我也标记的新行和不同行!这些是汇编输出的唯一变化!其他每一行都是完全相同的。下面标记的新行发出并实际读取变量的新值并将其存储到寄存器eax中。接下来,在准备打印时,不是像以前那样将硬编码的内容移动到 register 中5,而是将刚刚读取的 register 的内容移动到 register 中,其中现在包含一个, 。解决了!添加修复它!esieax8esivolatile

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 值),我们可以使用 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 intto 转换的原因volatile int(即:为什么只是int引用或int指针)工作得很好,是因为volatile影响变量的读取,而不是变量的写入。因此,只要我们通过 volatile 变量方式读取变量,就可以保证我们的读取不会被优化。这就是为我们提供定义明确的行为的原因。写入总是有效——即使变量不是volatile.

参考:

  1. [我自己的回答] “安置新”有什么用?
  2. x86 组装指南
  3. 将对象的“this”指针更改为指向不同的对象
  4. 编译器资源管理器输出,带有汇编,来自 godbolt.org:
    1. 这里:https ://godbolt.org/z/EfPPba
    2. 在这里:https ://godbolt.org/z/6K8dcG
  5. [我的答案] STM32 微控制器上的寄存器级 GPIO 访问:像 STM8 一样编程 STM32(寄存器级 GPIO)
于 2020-09-15T06:12:55.663 回答
0

阅读此链接:

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

尤其...

据称这个技巧可以防止代码重复。但是,它有一些严重的缺陷。为了工作,C 的析构函数必须为它已删除的每个指针分配 NULLify,因为随后的复制构造函数调用可能会在它为 char 数组重新分配新值时再次删除相同的指针。

于 2010-11-09T17:04:07.373 回答