"constructing"一个带有memcpy的可复制对象

"constructing" a trivially-copyable object with memcpy

本文关键字:memcpy 对象 可复制 一个 constructing      更新时间:2023-10-16

在C++中,这段代码是否正确?

#include <cstdlib>
#include <cstring>
struct T   // trivially copyable type
{
int x, y;
};
int main()
{
void *buf = std::malloc( sizeof(T) );
if ( !buf ) return 0;
T a{};
std::memcpy(buf, &a, sizeof a);
T *b = static_cast<T *>(buf);
b->x = b->y;
free(buf);
}

换句话说,*b是一个生命已经开始的对象吗?(如果是这样,它究竟是什么时候开始的?

这是未指定的,N3751支持:对象生存期,低级编程和 Memcpy 其中说:

C++标准目前对使用memcpy是否对memcpy的使用保持沉默。 复制对象表示字节在概念上是一个赋值或 对象构造。差异对于基于语义的语义确实很重要 程序分析和转换工具,以及优化器, 跟踪对象生存期。本文认为

  1. 使用 memcpy 复制两个不同的平凡可复制表(但大小相同)的两个不同对象的字节是 允许

  2. 这种用法被识别为初始化,或者更一般地被识别为(概念上)对象构造。

识别为对象构造将支持二进制 IO,同时仍然 允许基于生命周期的分析和优化器。

我找不到任何讨论过这篇论文的会议记录,所以它似乎仍然是一个悬而未决的问题。

C++14标准草案目前在1.8[intro.object]中说:

[...]对象由定义 (3.1) 和新表达式创建 (5.3.4)或在需要时由(12.2)实现。[...]

我们在malloc中没有这种情况,并且复制琐碎可复制类型的标准中涵盖的情况似乎仅引用第3.9[basic.types]部分中已经存在的对象:

对于任何对象(基类子对象除外)的微不足道 可复制类型 T,无论对象是否具有有效的类型值 T,构成对象的底层字节(1.7)可以复制到 字符数组或无符号字符.42 如果数组的内容 字符或无符号字符被复制回对象中,对象应 随后保持其原始价值[...]

和:

对于任何可平凡复制的类型 T,如果指向 T 的两个指针指向 不同的 T 对象 obj1 和 obj2,其中 obj1 和 obj2 都不是 基类子对象,如果构成 obj1 的基础字节 (1.7) 是 复制到 OBJ2,43 OBJ2 随后应保持与 对象1.[...]

这基本上就是提案所说的,所以这应该不足为奇。

DYP指出了UB邮件列表中关于这个话题的一个有趣的讨论:[ub]键入双关语以避免复制。

Propoal p0593:隐式创建用于低级对象操作

的对象提案p0593试图解决这个问题,但AFAIK尚未经过审查。

本文建议在新分配的存储中根据需要按需创建足够简单的对象,以便为程序提供定义的行为。

它有一些本质上相似的激励示例,包括当前的std::vector实现,该实现当前具有未定义的行为。

它提出了以下隐式创建对象的方法:

我们建议至少将以下操作指定为隐式创建对象:

  • 创建 char、无符号 char 或 std::byte 数组会隐式创建该数组中的对象。

  • 对 malloc、calloc、realloc 或任何名为运算符 new 或运算符 new[] 的函数的调用会在其返回的存储中隐式创建对象。

  • std::allocator::assign 同样在其返回的存储中隐式创建对象;分配器要求应要求其他分配器实现也这样做。

  • 对 memmove 的调用行为就像它一样

    • 将源存储复制到临时区域

    • 目标存储中隐式创建对象,然后

    • 将临时存储复制到目标存储。

    这允许 memmove 保留简单可复制对象的类型,或者用于将一个对象的字节表示形式重新解释为另一个对象的字节表示形式。

  • 对 memcpy 的调用与对 memmove 的调用行为相同,只是它在源和目标之间引入了重叠限制。

  • 指定联合成员的
  • 类成员访问会在联合成员占用的存储器中触发隐式对象创建。请注意,这不是一个全新的规则:对于成员访问权限位于赋值左侧的情况,此权限在 [P0137R1] 中已经存在,但现在已作为此新框架的一部分进行通用化。如下所述,这不允许通过联合进行类型双关语;相反,它仅允许类成员访问表达式更改活动的联合成员。

  • 应该在标准库中引入一个新的屏障操作(不同于不创建对象的 std::launder),其语义等同于具有相同源和目标存储的 memmove。作为稻草人,我们建议:

    // Requires: [start, (char*)start + length) denotes a region of allocated
    // storage that is a subset of the region of storage reachable through start.
    // Effects: implicitly creates objects within the denoted region.
    void std::bless(void *start, size_t length);
    

除上述内容外,还应将一组实现定义的非 stasndard 内存分配和映射函数(例如 POSIX 系统上的 mmap 和 Windows 系统上的 VirtualAlloc)指定为隐式创建对象。

请注意,指针reinterpret_cast不足以触发隐式对象创建。

该代码现在是合法的,并且自 98 年 C++ 起追溯!

@Shafik Yaghmour的回答是彻底的,并且与代码有效性作为一个悬而未决的问题有关 - 回答时就是这种情况。沙菲克的回答正确地引用了p0593,在回答时它是一个提案。但从那以后,这个提议被接受了,事情也得到了定义。

一些历史

在 C++20 之前的 C++ 规范中没有提到使用malloc创建对象的可能性,例如参见 C++17 规范 [intro.object]:

C++程序中的构造创建、销毁、引用、访问和操作 对象。对象由定义 (6.1)、新表达式 (8.5.2.4) 创建, 当隐式更改工会的活动成员 (12.3) 时,或当临时 对象已创建 (7.4、15.2)。

上面的措辞并没有将malloc作为创建对象的选项,因此使其成为事实上的未定义行为。

然后它被视为一个问题,这个问题后来由 https://wg21.link/P0593R6 解决,并被接受为自 C++98 年以来所有 C++ 版本的 DR,然后添加到 C++20 规范中,新措辞如下:

[介绍对象]

  1. C++程序中的构造创建、销毁、引用、访问和操作对象。对象是由定义、新表达式、隐式创建对象的操作创建的(见下文)...

  1. 此外,在指定区域内隐式创建对象后 存储,某些操作被描述为生成指向 合适的创建对象。这些操作选择其中一个 隐式创建的对象,其地址是开始的地址 ,并生成指向 该对象,如果该值将导致程序已定义 行为。如果没有这样的指针值,将给出定义的程序 行为,程序的行为是未定义的。如果多个这样的 指针值将给出程序定义的行为,它是 未指定生成哪个此类指针值。

C++20 规范中给出的示例是:

#include <cstdlib>
struct X { int a, b; };
X *make_x() {
// The call to std​::​malloc implicitly creates an object of type X
// and its subobjects a and b, and returns a pointer to that X object
// (or an object that is pointer-interconvertible ([basic.compound]) with it), 
// in order to give the subsequent class member access operations   
// defined behavior. 
X *p = (X*)std::malloc(sizeof(struct X));
p->a = 1;   
p->b = 2;
return p;
}

至于memcpy的使用 - @Shafik Yaghmour 已经解决了这个问题,这部分对平凡可复制的类型有效(措辞从 C++98 和 C++03 中的POD更改为 C++11 及之后的可复制类型)。


底线:代码有效。

至于生命周期的问题,让我们深入研究有问题的代码:

struct T   // trivially copyable type
{
int x, y;
};
int main()
{
void *buf = std::malloc( sizeof(T) ); // <= just an allocation
if ( !buf ) return 0;
T a{}; // <= here an object is born of course
std::memcpy(buf, &a, sizeof a);      // <= just a copy of bytes
T *b = static_cast<T *>(buf);        // <= here an object is "born"
//    without constructor    
b->x = b->y;
free(buf);
} 

请注意,为了完整起见,可以在释放buf之前,向*b的析构函数添加一个调用:

b->~T();
free(buf);

尽管规范不要求这样做。

或者,删除 b也是一种选择:

delete b;
// instead of:
// free(buf);

但如前所述,代码按原样有效。

来自快速搜索。

"...生存期从为对象分配正确对齐的存储时开始,到存储被另一个对象解除分配或重用时结束。

所以,我想说,根据这个定义,生命周期从分配开始,到免费结束。

这段代码正确吗?

好吧,它通常会"工作",但仅适用于琐碎的类型。

我知道你没有要求它,但让我们使用一个非平凡类型的示例:

#include <cstdlib>
#include <cstring>
#include <string>
struct T   // trivially copyable type
{
std::string x, y;
};
int main()
{
void *buf = std::malloc( sizeof(T) );
if ( !buf ) return 0;
T a{};
a.x = "test";
std::memcpy(buf, &a, sizeof a);    
T *b = static_cast<T *>(buf);
b->x = b->y;
free(buf);
}

构造a后,a.x被赋值。 假设std::string没有优化为对小字符串值使用本地缓冲区,只是指向外部内存块的数据指针。memcpy()将按原样a的内部数据复制到buf中。 现在,a.xb->x引用string数据的相同内存地址。 当b->x被分配一个新值时,该内存块将被释放,但a.x仍引用它。 当amain()结束时超出范围时,它会再次尝试释放相同的内存块。 发生未定义的行为。

如果你想"正确",将对象构造到现有内存块中的正确方法是改用placement-new运算符,例如:

#include <cstdlib>
#include <cstring>
struct T   // does not have to be trivially copyable
{
// any members
};
int main()
{
void *buf = std::malloc( sizeof(T) );
if ( !buf ) return 0;
T *b = new(buf) T; // <- placement-new
// calls the T() constructor, which in turn calls
// all member constructors...
// b is a valid self-contained object,
// use as needed...
b->~T(); // <-- no placement-delete, must call the destructor explicitly
free(buf);
}