为什么对于非TriviallyCopyable的对象,未定义std :: memcpy的行为?

时间:2015-04-21 16:03:25

标签: c++ c++11 language-lawyer memcpy object-lifetime

来自http://en.cppreference.com/w/cpp/string/byte/memcpy

  

如果对象不是TriviallyCopyable(例如标量,数组,C兼容结构),则行为未定义。

在我的工作中,我们使用std::memcpy很长时间来使用以下方式按比例交换非TriviallyCopyable的对象:

void swapMemory(Entity* ePtr1, Entity* ePtr2)
{
   static const int size = sizeof(Entity); 
   char swapBuffer[size];

   memcpy(swapBuffer, ePtr1, size);
   memcpy(ePtr1, ePtr2, size);
   memcpy(ePtr2, swapBuffer, size);
}

并且从未遇到过任何问题。

我理解将std::memcpy与非TriviallyCopyable对象滥用并导致下游未定义的行为是微不足道的。但是,我的问题是:

为什么在与非TriviallyCopyable对象一起使用时,std::memcpy本身的行为是不确定的?为什么标准认为有必要指定?

更新

http://en.cppreference.com/w/cpp/string/byte/memcpy的内容已根据此帖和帖子的回答进行了修改。目前的描述说:

  

如果对象不是TriviallyCopyable(例如标量,数组,C兼容结构),则行为是未定义的,除非程序不依赖于目标对象的析构函数的效果(不运行) memcpy)和目标对象的生命周期(已结束,但不是由memcpy启动)是通过其他方式启动的,例如placement-new。

PS

@Cubbi的评论:

  

@RSahu如果有东西保证UB下游,它会使整个程序未定义。但我同意在这种情况下似乎可以绕过UB并相应​​地修改cppreference。

10 个答案:

答案 0 :(得分:38)

  

为什么在与非TriviallyCopyable对象一起使用时,std::memcpy本身的行为是不确定的?

不是!但是,一旦将非平凡可复制类型的一个对象的基础字节复制到该类型的另一个对象中,目标对象就不存在。我们通过重新使用它的存储来销毁它,并且通过构造函数调用它并没有使它恢复活力。

使用目标对象 - 调用其成员函数,访问其数据成员 - 显然未定义 [basic.life] / 6 ,因此是后续的隐式析构函数调用 [basic .life] / 4 用于具有自动存储持续时间的目标对象。请注意未定义的行为是如何回顾性的。 [intro.execution] / 5:

  

但是,如果任何此类执行包含未定义的操作,则此操作   国际标准对实施没有要求   用该输入执行该程序(甚至没有关于   第一个未定义操作之前的操作)。

如果一个实现发现一个对象如何死亡并且必然会受到未定义的进一步操作的影响,......它可能会通过改变你的程序语义来做出反应。从memcpy致电开始。一旦我们考虑优化器和它们所做的某些假设,这种考虑就变得非常实用。

应该注意的是,标准库能够并且允许优化某些标准库算法以用于简单的可复制类型。 std::copy指向普通可复制类型的指针通常会在底层字节上调用memcpyswap 也是如此 因此,只需坚持使用普通的通用算法,让编译器进行任何适当的低级优化 - 这部分是因为一开始就发明了一个简单的可复制类型的想法:确定某些优化的合法性。此外,这可以避免因担心语言中矛盾和不明确的部分而伤害你的大脑。

答案 1 :(得分:22)

因为标准是这样说的。

编译器可能会假设非TriviallyCopyable类型仅通过其复制/移动构造函数/赋值运算符进行复制。这可能是出于优化目的(如果某些数据是私有的,它可能会推迟设置,直到发生复制/移动)。

编译器甚至可以自由地进行memcpy调用并让不执行任何操作,或格式化您的硬盘驱动器。为什么?因为标准是这样说的。什么都不做肯定比移动位更快,所以为什么不优化你的memcpy到一个同样有效的更快的程序?

现在,在实践中,当您只是在不期望它的类型中对位进行blit时,可能会出现许多问题。虚拟功能表可能未正确设置。用于检测泄漏的仪器可能无法正确设置。身份包含其位置的对象会被您的代码完全弄乱。

真正有趣的部分是using std::swap; swap(*ePtr1, *ePtr2);应该能够被编译器编译为memcpy以获得简单的可复制类型,并且其他类型可以被定义为行为。如果编译器可以证明副本只是被复制的位,则可以将其更改为memcpy。如果您可以编写更优的swap,则可以在相关对象的命名空间中执行此操作。

答案 2 :(得分:22)

构建一个基于memcpy的{​​{1}}打破的类很容易:

swap

struct X { int x; int* px; // invariant: always points to x X() : x(), px(&x) {} X(X const& b) : x(b.x), px(&x) {} X& operator=(X const& b) { x = b.x; return *this; } }; 这样的对象打破了不变量。

GNU C ++ 11 memcpy与短字符串完全相同。

这类似于标准文件和字符串流的实现方式。流最终派生自std::string,其中包含指向std::basic_ios的指针。流还包含特定缓冲区作为成员(或基类子对象),std::basic_streambuf中的指针指向该成员。

答案 3 :(得分:15)

C ++不保证所有类型的对象占用连续的存储字节[intro.object] / 5

  

平凡可复制或标准布局类型(3.9)的对象   占用连续的存储字节。

实际上,通过虚拟基类,您可以在主要实现中创建非连续对象。我试图构建一个示例,其中对象x的基类子对象位于x的起始地址之前的。要想象这一点,请考虑下面的图/表,其中水平轴是地址空间,垂直轴是继承级别(级别1继承自级别0)。标记为dm的字段由该类的直接数据成员占用。

L | 00 08 16
--+---------
1 |    dm
0 | dm

这是使用继承时的常用内存布局。但是,虚拟基类子对象的位置并不固定,因为它可以通过子类重新定位,子类也虚拟地从相同的基类继承。这可能导致级别1(基类子)对象报告它从地址8开始并且大16字节的情况。如果我们天真地添加这两个数字,我们认为它占据了地址空间[8,24],即使它实际占据[0,16]。

如果我们可以创建这样的1级对象,那么我们就不能使用memcpy来复制它:memcpy将访问不属于该对象的内存(地址16到24)。在我的演示中,被clang ++的地址清理程序捕获为堆栈缓冲区溢出。

如何构建这样的对象?通过使用多个虚拟继承,我想出了一个具有以下内存布局的对象(虚拟表指针标记为vp)。它由四层继承组成:

L  00 08 16 24 32 40 48
3        dm         
2  vp dm
1              vp dm
0           dm

上述问题将出现在1级基类子对象中。它的起始地址是32,它是24字节大(vptr,它自己的数据成员和0级数据成员)。

这里是clang ++和g ++ @ coliru下的内存布局代码:

struct l0 {
    std::int64_t dummy;
};

struct l1 : virtual l0 {
    std::int64_t dummy;
};

struct l2 : virtual l0, virtual l1 {
    std::int64_t dummy;
};

struct l3 : l2, virtual l1 {
    std::int64_t dummy;
};

我们可以按如下方式生成堆栈缓冲区溢出:

l3  o;
l1& so = o;

l1 t;
std::memcpy(&t, &so, sizeof(t));

这是一个完整的演示,还会打印一些有关内存布局的信息:

#include <cstdint>
#include <cstring>
#include <iomanip>
#include <iostream>

#define PRINT_LOCATION() \
    std::cout << std::setw(22) << __PRETTY_FUNCTION__                   \
      << " at offset " << std::setw(2)                                  \
        << (reinterpret_cast<char const*>(this) - addr)                 \
      << " ; data is at offset " << std::setw(2)                        \
        << (reinterpret_cast<char const*>(&dummy) - addr)               \
      << " ; naively to offset "                                        \
        << (reinterpret_cast<char const*>(this) - addr + sizeof(*this)) \
      << "\n"

struct l0 {
    std::int64_t dummy;

    void report(char const* addr) { PRINT_LOCATION(); }
};

struct l1 : virtual l0 {
    std::int64_t dummy;

    void report(char const* addr) { PRINT_LOCATION(); l0::report(addr); }
};

struct l2 : virtual l0, virtual l1 {
    std::int64_t dummy;

    void report(char const* addr) { PRINT_LOCATION(); l1::report(addr); }
};

struct l3 : l2, virtual l1 {
    std::int64_t dummy;

    void report(char const* addr) { PRINT_LOCATION(); l2::report(addr); }
};

void print_range(void const* b, std::size_t sz)
{
    std::cout << "[" << (void const*)b << ", "
              << (void*)(reinterpret_cast<char const*>(b) + sz) << ")";
}

void my_memcpy(void* dst, void const* src, std::size_t sz)
{
    std::cout << "copying from ";
    print_range(src, sz);
    std::cout << " to ";
    print_range(dst, sz);
    std::cout << "\n";
}

int main()
{
    l3 o{};
    o.report(reinterpret_cast<char const*>(&o));

    std::cout << "the complete object occupies ";
    print_range(&o, sizeof(o));
    std::cout << "\n";

    l1& so = o;
    l1 t;
    my_memcpy(&t, &so, sizeof(t));
}

Live demo

示例输出(缩写为避免垂直滚动):

l3::report at offset  0 ; data is at offset 16 ; naively to offset 48
l2::report at offset  0 ; data is at offset  8 ; naively to offset 40
l1::report at offset 32 ; data is at offset 40 ; naively to offset 56
l0::report at offset 24 ; data is at offset 24 ; naively to offset 32
the complete object occupies [0x9f0, 0xa20)
copying from [0xa10, 0xa28) to [0xa20, 0xa38)

请注意两个强调的结束偏移。

答案 4 :(得分:5)

这些答案中的许多都提到memcpy可能会破坏类中的不变量,这会在以后导致未定义的行为(在大多数情况下应该足够的理由不冒风险),但这并不是似乎是你真正要求的。

为什么memcpy调用本身被认为是未定义的行为的一个原因是为编译器提供尽可能多的空间以基于目标平台进行优化。通过让调用本身成为UB,编译器允许做奇怪的,依赖于平台的事情。

考虑这个(非常人为的和假设的)示例:对于特定的硬件平台,可能存在几种不同类型的存储器,其中一些存储器比不同的存储器更快。例如,可能存在一种允许额外快速存储器复制的特殊存储器。因此,允许此(虚构)平台的编译器将所有TriviallyCopyable类型放在此特殊内存中,并实现memcpy以使用仅适用于此内存的特殊硬件指令。

如果您在此平台上的非memcpy对象上使用TriviallyCopyablememcpy调用本身可能会出现一些低级别的INVALID OPCODE崩溃

也许不是最有说服力的论据,但重点是标准不禁止,这只能通过memcpy 调用来实现 UB。

答案 5 :(得分:3)

memcpy将复制所有字节,或者在你的情况下交换所有字节,就好了。过度热心的编译器可以采用&#34;未定义的行为&#34;作为各种恶作剧的借口,但大多数编制者都不会这样做。不过,这是有可能的。

但是,复制这些字节后,复制它们的对象可能不再是有效对象。简单的情况是一个字符串实现,其中大字符串分配内存,但小字符串只是使用字符串对象的一部分来保存字符,并保持指向它的指针。指针显然会指向另一个对象,所以事情会出错。我看到的另一个例子是一个只有很少实例的数据类,所以数据保存在数据库中,对象的地址作为键。

现在,如果您的实例包含互斥锁,我认为移动它可能是一个主要问题。

答案 6 :(得分:1)

memcpy是UB的另一个原因(除了在其他答案中提到的 - 它可能会在以后打破不变量)是标准很难准确地说出会发生什么

对于非平凡类型,标准很少说明对象在内存中的排列方式,成员的放置顺序,vtable指针所在的位置,填充应该是什么,等等。编译器具有巨大的功能。决定这一点的自由度。

因此,即使标准想要在这些“安全”的情况下允许memcpy,也不可能说明什么情况是安全的,哪些不是,或者什么时候真正的UB是因不安全案件而被触发。

我认为你可以说这些效果应该是实现定义的或未指定的,但我个人觉得这样做会对平台细节进行深入挖掘,并且对于某些内容的某些内容有点过分合法性。一般情况相当不安全。

答案 7 :(得分:1)

首先,请注意,可变C / C ++对象的所有内存必须是非类型的,非专用的,可用于任何可变对象,这是不容置疑的。 (我猜全局常量变量的内存可以假设是类型化的,对于这种微小的角落情况,这种超级复杂化没有任何意义。)与Java不同,C ++没有动态对象的类型分配: Java中的new Class(args)是一种类型化对象创建:创建一个定义良好的类型的对象,它可能存在于类型化的内存中。另一方面,C ++表达式new Class(args)只是一个围绕无类型内存分配的瘦打字包装器,与new (operator new(sizeof(Class)) Class(args)等效:对象是在&#34;中性内存&#34;中创建的。改变这意味着改变C ++的很大一部分。

在某些类型上禁止位复制操作(无论是由memcpy还是由等效的用户定义的逐字节复制完成)为多态类(具有虚函数的那些)的实现提供了很大的自由度,以及其他如此叫做#34;虚拟课程&#34; (不是标准术语),即使用virtual关键字的类。

多态类的实现可以使用地址的全局关联映射,该映射将多态对象的地址与其虚函数相关联。我相信这是在第一次迭代C ++语言(甚至是&#34; C with classes&#34;)的设计过程中认真考虑的一个选项。该多态对象映射可能使用特殊的CPU功能和特殊的关联存储器(这些功能不会暴露给C ++用户)。

当然我们知道虚函数的所有实际实现都使用vtable(描述类的所有动态方面的常量记录)并在每个多态基类子对象中放置一个vptr(vtable指针),因为这种方法非常简单实施(至少对于最简单的情况)并且非常有效。在任何实际的实现中都没有多态对象的全局注册表,除非可能在调试模式下(我不知道这样的调试模式)。

C ++标准通过说你可以在重用对象的内存时跳过析构函数调用,使得缺少全局注册表有点官方,只要你不依赖它&#34;副作用&#34;析构函数调用。 (我认为这意味着&#34;副作用&#34;是用户创建的,这是析构函数的主体,而不是实现创建的,因为实现会自动对析构函数执行。)

因为在实践中所有实现中,编译器只使用vptr(指向vtable的指针)隐藏成员,并且这些隐藏成员将被memcpy正确复制;好像你做了一个简单的成员明智的C结构副本,代表多态类(包含所有隐藏的成员)。按位副本或完整的C struct成员明智副本(完整的C结构包含隐藏成员)将完全像构造函数调用一样(由placement new执行),因此所有你必须做的就让编译器认为你可能有人称之为新的安置。如果执行强外部函数调用(对无法内联的函数的调用以及编译器无法检查其实现,如调用动态加载的代码单元中定义的函数或系统调用),则编译器只会假设这样的构造函数可能已被它无法检查的代码调用。 因此memcpy的行为不是由语言标准定义,而是由编译器ABI(应用程序二进制接口)定义。强外部函数调用的行为由ABI定义,不仅仅是语言标准。对可能无法使用的函数的调用由语言定义,因为可以看到它的定义(在编译期间或链接时全局优化期间)。

所以在实践中,给定适当的编译器围栏&#34; (例如调用外部函数,或只是asm("")),您可以memcpy只使用虚函数的类。

当然,当您执行memcpy时,您必须被语言语义所允许才能执行此类放置:您无法毫不犹豫地重新定义现有对象的动态类型,并假装您没有简单地破坏旧的对象。如果你有一个非const全局,静态,自动,成员子对象,数组子对象,你可以覆盖它并在那里放入另一个不相关的对象;但如果动态类型不同,你不能假装它仍然是同一个对象或子对象:

struct A { virtual void f(); };
struct B : A { };

void test() {
  A a;
  if (sizeof(A) != sizeof(B)) return;
  new (&a) B; // OK (assuming alignement is OK)
  a.f(); // undefined
}

不允许更改现有对象的多态类型:除了内存区域之外,新对象与a无关:从&a开始的连续字节。他们有不同的类型。

[对于*&a是否可以使用(在典型的平面内存机器中)或(A&)(char&)a(在任何情况下)来引用新对象,标准分歧很大。编译器编写者没有分歧:你不应该这样做。这是C ++中的一个深层次缺陷,也许是最深刻和最令人不安的。]

但是你不能在可移植代码中执行使用虚拟继承的类的按位复制,因为一些实现使用指向虚拟基础子对象的指针实现这些类:这些指针由最派生对象的构造函数正确初始化将具有它们的由memcpy复制的值(就像表示具有所有隐藏成员的类的C结构的普通成员明智副本)并且不会指向派生对象的子对象!

其他ABI使用地址偏移来定位这些基础子对象;它们仅依赖于派生最多的对象的类型,如最终覆盖和typeid,因此可以存储在vtable中。在这些实现中,memcpy将由ABI保证(具有更改现有对象类型的上述限制)。

在任何一种情况下,它都完全是一个对象表示问题,即ABI问题。

答案 8 :(得分:0)

我能在这里看到的是 - 对于某些实际应用 - C ++标准可能是限制性的,或者更确切地说,不是足够的。

如其他答案所示,memcpy会很快崩溃,因为&#34;复杂&#34;类型,但恕我直言,它实际上应该适用于标准布局类型,只要memcpy不会破坏标准布局类型的定义的复制操作和析构函数。 (注意,偶数TC类允许具有非平凡的构造函数。)标准只显式调用TC类型wrt。但是,这就是。

最近的报价草稿(N3797):

  

3.9类型

     

...

     

2 对于任何简单的对象(基类子对象除外)   可复制类型T,无论对象是否包含有效的类型值   T,构成对象的底层字节(1.7)可以复制到   char或unsigned char数组。如果是char数组的内容   或者将unsigned char复制回对象,该对象应该   随后保持其原始价值。 [例如:

  #define N sizeof(T)
  char buf[N];        T obj; // obj initialized to its original value
  std::memcpy(buf, &obj, N); // between these two calls to std::memcpy,       
                             // obj might be modified         
  std::memcpy(&obj, buf, N); // at this point, each subobject of obj of scalar type
                             // holds its original value 
     

-end example]

     

3 对于任何简单的可复制类型T,如果指向T的两个指针指向   不同的T对象obj1和obj2,其中obj1和obj2都不是   基类子对象,如果构成obj1的基础字节(1.7)是   复制到obj2中,obj2随后应保持与obj1相同的值。   [例如:

T* t1p;
T* t2p;       
     // provided that t2p points to an initialized object ...         
std::memcpy(t1p, t2p, sizeof(T));  
     // at this point, every subobject of trivially copyable type in *t1p contains        
     // the same value as the corresponding subobject in *t2p
     

-end example]

此处的标准涉及trivially copyable类型,但上面的@dyp为was observed,根据我的意见,还有standard layout types不一定与可复制的类型。

标准说:

  

1.8 C ++对象模型

     

(...)

     

5 (...)平凡可复制或标准布局类型(3.9)的对象应占用连续的存储字节。

所以我在这里看到的是:

  • 该标准没有说明关于非平凡可复制类型的内容。 memcpy。 (已在这里多次提到)
  • 标准对标准布局类型有一个单独的概念,占据连续存储。
  • 标准明确允许也不允许在可简单复制的标准布局对象上使用memcpy

所以它似乎明确地调出了UB,但它当然也不是所谓的unspecified behavior,所以可以得出@underscore_d所做的结论在对已接受答案的评论中:

  

(...)你不能只说'#34;嗯,它   没有被明确地称为UB,因此它被定义了   行为!&#34;,这就是这个线程似乎达到的目的。 N3797 3.9   点2~3没有定义memcpy对非平凡可复制的内容   对象,所以(...)[t]帽子功能非常强大   相当于我眼中的UB,因为它们对于编写可靠的,即便携式代码都是无用的

我个人会得出结论,就可移植性而言,它相当于UB(哦,那些优化器),但我认为通过一些对冲和具体实现的知识,可以逃脱它。 (只要确保它值得一试。)

附注:我还认为标准确实应该将标准布局类型语义明确地合并到整个memcpy混乱中,因为它是一个有效且有用的用例来执行非平凡可复制对象的按位复制,但这不是重点。

链接:Can I use memcpy to write to multiple adjacent Standard Layout sub-objects?

答案 9 :(得分:0)

好吧,让我们尝试一个示例:

#include <iostream>
#include <string>
#include <string.h>

void swapMemory(std::string* ePtr1, std::string* ePtr2) {
   static const int size = sizeof(*ePtr1);
   char swapBuffer[size];

   memcpy(swapBuffer, ePtr1, size);
   memcpy(ePtr1, ePtr2, size);
   memcpy(ePtr2, swapBuffer, size);
}

int main() {
  std::string foo = "foo", bar = "bar";
  std::cout << "foo = " << foo << ", bar = " << bar << std::endl;
  swapMemory(&foo, &bar);
  std::cout << "foo = " << foo << ", bar = " << bar << std::endl;
  return 0;
}

在我的机器上,这会在崩溃前打印以下内容:

foo = foo, bar = bar
foo = foo, bar = bar

很奇怪,是吗?交换似乎根本没有执行。好了,内存被交换了,但是std::string在我的机器上使用了小字符串优化:它将短字符串存储在std::string对象本身一部分的缓冲区中,并指向其内部数据指针在那个缓冲区。

swapMemory()交换字节时,它将交换指针和缓冲区。因此,foo对象中的指针现在指向bar对象中的存储,该存储中现在包含字符串"foo"。二级交换不交换。

std::string的析构函数随后尝试清理时,会发生更多的恶魔:数据指针不再指向std::string自己的内部缓冲区,因此析构函数推断出该内存必须已分配在堆上,并尝试delete。我的机器上的结果是程序的简单崩溃,但是C ++标准并不关心是否会出现粉红色大象。该行为是完全不确定的。


这就是为什么您不应该在不可复制的对象上使用memcpy()的根本原因:您不知道对象是否包含指向其自身数据成员的指针/引用,还是取决于其自身位置在内存中以任何其他方式。如果您memcpy()是这样的对象,则会违反该对象无法在内存中移动的基本假设,并且某些类似std::string的类也依赖此假设。 C ++标准在(不可复制的)可复制对象之间的区别上划了界线,以避免涉及更多关于指针和引用的不必要的细节。它仅对平凡可复制的对象例外,并说:好吧,在这种情况下,您是安全的。但是,如果您尝试memcpy()其他任何物体,请不要怪我。