用 memcpy “构造”一个​​可简单复制的对象

Posted

技术标签:

【中文标题】用 memcpy “构造”一个​​可简单复制的对象【英文标题】:"constructing" a trivially-copyable object with memcpy 【发布时间】:2015-07-18 19:30:33 【问题描述】:

在 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 是一个生命周期已经开始的对象吗? (如果有,具体是什么时候开始的?)

【问题讨论】:

相关:***.com/questions/26171827/… 我能想到的唯一潜在问题是strict aliasing。您可以通过更改 buf 的类型来更正,在这种情况下,我会说 bbuff 是相同的,因此具有相同的生命周期。 @nonsensickle 我认为严格的别名在这里不适用:如果*bT 类型的对象,那么这样使用它就不会违反别名;如果不是,那么它就是 UB,因为 b-&gt;y 试图读取一个不存在的对象。当然改变buf的类型没有区别;转换指针不会改变它指向的对象的动态类型 是的,我认为你是对的。只要您不使用 buf 作为 lvalue 它不应该违反严格的别名规则。我撤回我的论点,但如果你不介意,我会留下评论。 【参考方案1】:

这是由N3751: Object Lifetime, Low-level Programming, and memcpy 支持的未指定,其中包括:

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

    使用 memcpy 复制两个不同的普通可复制表(但大小相同)的两个不同对象的字节 允许

    这样的使用被认为是初始化,或更一般地是(概念上)对象构造。

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

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

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

[...]一个对象是由一个定义(3.1),由一个新表达式创建的 (5.3.4)或在需要时由实施(12.2)。[...]

malloc 所没有的,标准中涵盖的用于复制普通可复制类型的案例似乎只引用了3.9 部分中已经存在的对象[basic.types]

对于平凡的任何对象(基类子对象除外) 可复制类型 T,无论对象是否拥有类型的有效值 T,构成对象的底层字节(1.7)可以复制到 char 或 unsigned char 的数组。42 如果数组的内容为 char 或 unsigned char 被复制回对象,对象应 随后保持其原始值[...]

和:

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

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

dyp 在 ub 邮件列表中指出了关于这个主题的精彩讨论:[ub] Type punning to avoid copying。

提案 p0593:为低级对象操作隐式创建对象

提案 p0593 试图解决此问题,但尚未审查 AFAIK。

本文建议在新分配的存储中按需创建足够简单类型的对象,以赋予程序定义的行为。

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

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

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

char、unsigned char 或 std::byte 数组的创建隐式地在该数组中创建对象。

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

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

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

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

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

【讨论】:

@dyp 哇,这是一个很棒的讨论,需要一段时间来消化它,但它是无价的,谢谢你指出这一点。 不幸的是,据我所知,它是不完整的(开头缺失,结论充其量是模糊的恕我直言)。 我认为您的意思是“未指定”而不是“未指定”(后一个术语在 C++ 标准中具有特定含义)? 我还有一个推论问题(不确定是否值得将其作为一个单独的问题发布);如果T 有一个重要的默认构造函数,你觉得会有什么不同吗? (但仍然可以轻松复制)。 另一方面,“memcpy 是否创建对象”问题似乎更多地受到对普通可复制类型的通用操作的推动。例如,当std::vector 需要扩展和复制它的由可简单复制的T 对象组成的底层存储时,它似乎很“明显”,它可以简单地分配新的更大尺寸的未初始化存储,而memcpy 现有的over对象(实际上该标准明确保证两个T 对象之间的此类副本是明确定义的)。但这是不允许的,因为未初始化的存储中还没有T 对象。【参考方案2】:

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

@Shafik Yaghmour 的回答是彻底的,并且将代码有效性作为一个未解决的问题 - 回答时就是这种情况。 Shafik 的回答正确地引用了 p0593,在回答时它是一个提案。但从那时起,提案被接受,事情得到了明确。

一些历史

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

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

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

它是then viewed as a problem,这个问题后来由https://wg21.link/P0593R6 解决,并被接受为对自 C++98 (包括 C++98)以来所有 C++ 版本的 DR,然后添加到 C++20 规范中,并带有新的措辞:

[intro.object]

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

...

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

C++20 规范中给出的example 是:

#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+ 中的 POD +98 和 C++03 到 普通可复制类型 in 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);

虽然this is not required by the spec.

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

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

但如前所述,代码是有效的。

【讨论】:

【参考方案3】:

来自a quick search。

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

所以,按照这个定义,生命周期从分配开始,到空闲结束。

【讨论】:

void *buf = malloc( sizeof(T) ) 创建了T 类型的对象似乎有点可疑。毕竟,它同样可以创建大小为 sizeof(T) 的任何类型的对象,我们还不知道这段代码是否会继续指向 T *bU *u 例如 @nonsensickle 我希望有一个“语言律师”质量的答案,例如来自 C++ 标准的文本以支持 malloc 可以被认为是一个微不足道的构造函数 @MattMcNabb,来自malloc 的内存“没有声明的类型”。 ***.com/questions/31483064/… 因此,它的有效类型可以在其生命周期内多次更改;每次写入时都会采用写入数据的类型。特别是,该答案引用了memcpy 如何复制源数据的有效类型。但我猜那是 C,而不是 C++,也许它是不同的 @curiousguy:如果没有“有效类型”的概念,严格的别名规则将毫无意义。另一方面,我认为基于类型的别名规则的概念本身就是一个错误,因为它同时迫使程序员使用memcpymemmove 编写效率低下的代码,并希望优化器能够修复它,而不允许在程序员知道(并且可以告诉编译器)某些东西不会别名的情况下,编译器可以进行简单易用的优化。 @curiousguy:我想是的(这就是char得到特殊待遇的原因)?虽然我承认我不了解合法和不合法的所有规则,因为与通过添加 __cache(x) block 语句可以实现的规则相比,这些规则是可怕的,这将使编译器有权假设 @987654334 的值@ 不会以任何超出附加块控制的方式更改。任何编译器都可以仅通过将__cache(x) 作为一个扩展为空的宏来与这样的语句兼容,但它会允许编译器进行大量的注册...【参考方案4】:

这段代码正确吗?

嗯,它通常会“工作”,但只适用于琐碎的类型。

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

#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-&gt;x 指的是string 数据的相同内存地址。当b-&gt;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);

【讨论】:

struct T 包含 ::std::string 在 c++14 及更高版本中不可轻易复制 包含std::string 的对象从未被简单地复制。它看起来像一个复制+粘贴错误,问题中的代码有一个“可简单复制”的注释,当为答案编辑代码时,注释没有更新。

以上是关于用 memcpy “构造”一个​​可简单复制的对象的主要内容,如果未能解决你的问题,请参考以下文章

strcpy和memcpy的区别

java怎么样构造函数复制一个对象

strcpy和memcpy的区别

2022-04-09 STL容器vector与拷贝构造函数

C/C++C语言复制字符串及复制函数汇总(strcpy()/memcpy()/strncpy()/memmove())

memcpy 一个非 POD 对象