【问题标题】:Do we really need placement new-expressions?我们真的需要放置新表达式吗?
【发布时间】:2021-11-08 12:00:39
【问题描述】:

我试图理解 C++ 中的placement new-expressions

This Stack Overflow answer 声明T* p = new T(arg); 等价于

void* place = operator new(sizeof(T));  // storage allocation
T* p = new(place) T(arg);               // object construction

delete p; 等价于

p->~T();             // object destruction
operator delete(p);  // storage deallocation

为什么我们需要T* p = new(place) T(arg); 中的placement new-expression 来构造对象,下面的不是等价的吗?

T* p = (T*) place;
*p = T(arg);

【问题讨论】:

  • *p = T(arg); 是赋值,而不是构造。但是你不能对从未构造过的东西调用赋值。
  • @Evg 你是对的,构造了一个临时对象,并在其上调用了移动构造函数。但是问题是什么,你不能从临时对象移动到具有动态存储时长的对象吗?
  • 没有要移动的对象!它尚未建成。
  • 除了placement new,没有办法在预定的内存位置创建对象。
  • @Maggyero “并在其上调用移动构造函数。” - 不,如果你写*p = T(arg),调用的是move assignment operator,而不是move constructor。在未初始化的内存上调用赋值运算符是未定义的行为。

标签: c++ dynamic-memory-allocation placement-new object-construction


【解决方案1】:

首先要注意的是*p = T(arg);是一个赋值,而不是一个构造。

现在让我们阅读标准 ([basic.life]/1):

...T 类型的对象的生命周期开始于:

  • 获得了与 T 类型正确对齐和大小的存储,并且
  • 其初始化(如果有)已完成(包括空初始化)

对于一般类型T,如果使用放置new,则初始化可能已经完成,但事实并非如此。正在做

void* place = operator new(sizeof(T));
T* p = (T*)place;

不会开始 *p 的生命周期。

同一部分内容为 ([basic.life]/6):

...在对象的生命周期开始之前,但在分配对象将占用的存储空间之后...任何表示对象将...所在的存储位置的地址的指针都可能是使用但仅限于有限的方式。 ...如果出现以下情况,则程序具有未定义的行为: ...

  • 指针用于访问非静态数据成员或调用对象的非静态成员函数, ...

operator= 是一个非静态成员函数,执行*p = T(arg);,相当于p->operator=(T(arg)),会导致未定义的行为。

一个简单的例子是一个包含一个指针作为数据成员的类,该指针在构造函数中初始化并在赋值运算符中取消引用。如果没有放置 new,则不会调用构造函数,也不会初始化该指针 (complete example)。

【讨论】:

  • 完美,这正是我需要的信息。谢谢!
  • 关于您的简单示例,您能否提供代码,因为我不明白为什么不会调用构造函数。因为在*p = T(arg); 中,构造函数 is 被调用来创建一个临时对象,该对象然后绑定到移动赋值运算符的引用参数(正如你所解释的那样,这是未定义的行为)。
  • @Maggyero godbolt.org/z/PhzPYajcj 这是关于您要分配的对象的构造函数。
  • 我明白了,问题是使用取消引用的空指针是未定义的行为(参见DR 1102)。我刚刚编辑了您的答案以明确说明。
  • @Maggyero 不保证为空,未初始化。即使访问指针值本身也是未定义的行为。
【解决方案2】:

一个示例用例是一个包含非平凡类型的联合。您将必须显式构造非平凡成员并显式销毁它:

#include <iostream>

struct Var {
    enum class Type { INT = 0, STRING } type;
    union { int val; std::string name; };
    Var(): type(Type::INT), val(0) {}
    ~Var() { if (type == Type::STRING) name.~basic_string(); }
    Var& operator=(int i) {
        if (type == Type::STRING) {
            name.~basic_string();  // explicit destruction required
            type = Type::INT;
        }
        val = i;
        return *this;
    }
    Var& operator=(const std::string& str) {
        if (type != Type::STRING) {
            new (&name) std::string(str);  // in-place construction
            type = Type::STRING;
        } else
            name = str;
        return *this;
    }
};

int main() {
    Var var;      // var is default initialized with a 0 int
    var = 12;     // val assignment
    std::cout << var.val << "\n";
    var = "foo";  // name assignment
    std::cout << var.name << "\n";
    return 0;
}

从 C++17 开始,我们有 std::variant 类在后台执行此操作,但如果您使用 C++14 或更早版本,则必须手动完成

顺便说一句,真实世界的类应该包含流注入器和提取器,并且如果您不访问当前值,应该让 getter 能够引发异常。为简洁起见,此处省略...

【讨论】:

  • 非常有启发性的用例,谢谢!那是学习工会课程的机会。语句name = str;不应该在else子句中以避免构造后不必要的赋值吗?
【解决方案3】:

Placement new 有其用例。一个例子是小缓冲区优化以避免堆分配:

struct BigObject
{
    std::size_t a, b, c;
};

int main()
{    
    std::byte buffer[24];

    BigObject* ptr = new(buffer) BigObject {1, 2, 3};

    // use ptr ...

    ptr->~BigObject();
}

此示例将在buffer 内部创建一个BigObject 实例,该实例本身就是位于堆栈上的对象。如您所见,我们自己不在这里分配任何内存,因此我们也不会释放它(我们不在这里调用delete)。但是我们仍然需要通过调用析构函数来销毁对象。

在您的特定示例中放置 new 没有多大意义,因为您基本上自己完成了 new 运算符的工作。但是一旦你拆分了内存分配和对象构造,你就需要放置新的。


至于你的

T* p = (T*) place;
*p = T(arg);

示例:正如在 cmets 中已经提到的 Evg,您正在取消引用指向未初始化内存的指针。 p 还没有指向 T 对象,因此取消引用它是 UB。

【讨论】:

    猜你喜欢
    • 1970-01-01
    • 2012-12-19
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2011-08-18
    • 1970-01-01
    • 2010-12-28
    • 1970-01-01
    相关资源
    最近更新 更多