为什么 std::memcpy 的行为对于不可平凡复制的对象是未定义的?

Why would the behavior of std::memcpy be undefined for objects that are not TriviallyCopyable?

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

If the objects are not TriviallyCopyable (e.g. scalars, arrays, C-compatible structs), the behavior is undefined.

在我的工作中,我们使用 std::memcpy 很长时间来按位交换不可平凡复制的对象:

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 对象并在下游导致未定义的行为是微不足道的。但是,我的问题:

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

更新

已修改 http://en.cppreference.com/w/cpp/string/byte/memcpy 的内容以响应此 post 和 post 的答案。当前的描述是:

If the objects are not TriviallyCopyable (e.g. scalars, arrays, C-compatible structs), the behavior is undefined unless the program does not depend on the effects of the destructor of the target object (which is not run by memcpy) and the lifetime of the target object (which is ended, but not started by memcpy) is started by some other means, such as placement-new.

PS

@Cubbi 的评论:

@RSahu if something guarantees UB downstream, it renders the entire program undefined. But I agree that it appears to be possible to skirt around UB in this case and modified cppreference accordingly.

因为标准是这么说的。

编译器可能假设非 TriviallyCopyable 类型只能通过它们的 copy/move constructors/assignment 运算符复制。这可能是出于优化目的(如果某些数据是私有的,它可以推迟设置它直到发生复制/移动)。

编译器甚至可以自由接受您的 memcpy 调用并让它什么也不做,或者格式化您的硬盘。为什么?因为标准是这么说的。什么都不做肯定比四处移动比特要快,那么为什么不将您的 memcpy 优化为一个同样有效的更快的程序呢?

现在,在实践中,当您只是在不需要它的类型中 blit 周围时,可能会出现很多问题。虚拟功能表可能未正确设置。用于检测泄漏的仪器可能未正确设置。其身份包括其位置的对象会被您的代码完全搞砸。

真正有趣的部分是 using std::swap; swap(*ePtr1, *ePtr2); 应该能够被编译器编译为一个 memcpy 用于平凡可复制的类型,并且对于其他类型可以定义行为。如果编译器可以证明copy只是被复制的位,可以随意将其更改为memcpy。如果你能写一个更优化的 swap,你可以在相关对象的命名空间中这样做。

Why would the behavior of std::memcpy itself be undefined when used with non-TriviallyCopyable objects?

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

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

However, if any such execution contains an undefined operation, this International Standard places no requirement on the implementation executing that program with that input (not even with regard to operations preceding the first undefined operation).

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

应该注意的是,尽管如此,标准库能够并允许针对普通可复制类型优化某些标准库算法。 std::copy 指向平凡可复制类型的指针通常会在底层字节上调用 memcpyswap.
也是 因此,只需坚持使用普通的泛型算法,让编译器进行任何适当的低级优化——这部分是发明平凡可复制类型的想法的部分原因:确定某些优化的合法性。此外,这可以避免因担心语言中相互矛盾和未指定的部分而伤害您的大脑。

构建一个 class 很容易,其中基于 memcpyswap 中断:

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

memcpy这样的对象打破了不变性。

GNU C++11 std::string 使用短字符串就可以做到这一点。

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

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

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

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

如果您要在此平台上的非 TriviallyCopyable 对象上使用 memcpymemcpy 中可能会出现一些低级无效操作码崩溃 自称.

也许不是最有说服力的论点,但重点是标准 不禁止它 ,这只有通过使 memcpy 呼叫 UB.

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

对于非平凡的类型,标准很少说明对象在内存中的布局方式、成员的放置顺序、vtable 指针的位置、填充应该是什么等等。编译器在决定这个方面有很大的自由度。

因此,即使标准想要在这些 "safe" 情况下允许 memcpy,也无法说明哪些情况是安全的,哪些情况不安全,或者确切地说什么时候真正的 UB 将在不安全的情况下被触发。

我想你可能会争辩说效果应该是实现定义的或未指定的,但我个人认为这对平台细节的挖掘有点太深,并且给某些东西太多的合法性这在一般情况下是相当不安全的。

memcpy 将复制所有字节,或者在您的情况下交换所有字节,就可以了。一个过分热心的编译器可能会把 "undefined behaviour" 作为各种恶作剧的借口,但大多数编译器不会那样做。不过,这是可能的。

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

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

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

An object of trivially copyable or standard-layout type (3.9) shall occupy contiguous bytes of storage.

事实上,通过虚拟基础 classes,您可以在主要实现中创建非连续对象。我试图构建一个示例,其中对象 x 的基础 class 子对象位于 之前 x 的起始地址 。为了形象化这一点,请考虑以下 graph/table,其中水平轴是地址 space,垂直轴是继承级别(级别 1 继承自级别 0)。由 dm 标记的字段被 class.

direct 数据成员占用
L | 00 08 16
--+---------
1 |    dm
0 | dm

这是使用继承时的常见内存布局。然而,虚拟基础 class 子对象的位置不是固定的,因为它可以被同样从相同基础 class 虚拟继承的子 classes 重新定位。这可能导致第 1 级(基 class 子)对象报告它从地址 8 开始并且有 16 个字节大的情况。如果我们天真地将这两个数字相加,我们会认为它占用了地址 space [8, 24),即使它实际上占用了 [0, 16].

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

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

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

1 级基础 class 子对象会出现上述问题。它的起始地址是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)

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

我在这里能感觉到的是——对于一些实际应用——C++ 标准可能是限制性的,或者更确切地说,不够宽容。

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

最近的报价草稿 (N3797):

3.9 Types

...

2 For any object (other than a base-class subobject) of trivially copyable type T, whether or not the object holds a valid value of type T, the underlying bytes (1.7) making up the object can be copied into an array of char or unsigned char. If the content of the array of char or unsigned char is copied back into the object, the object shall subsequently hold its original value. [ Example:

  #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 For any trivially copyable type T, if two pointers to T point to distinct T objects obj1 and obj2, where neither obj1 nor obj2 is a base-class subobject, if the underlying bytes (1.7) making up obj1 are copied into obj2, obj2 shall subsequently hold the same value as obj1. [ Example:

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 types, but as by @dyp above, there are also standard layout types,据我所知,它不一定与 Trivially Copyable 类型重叠。

标准说:

1.8 The C++ object model

(...)

5 (...) An object of trivially copyable or standard-layout type (3.9) shall occupy contiguous bytes of storage.

所以我在这里看到的是:

  • 该标准对非平凡可复制类型只字不提。 memcpy。 (这里已经多次提到)
  • 对于占用连续存储空间的标准布局类型,标准有一个单独的概念。
  • 标准 明确允许或禁止在 不可 普通可复制的标准布局对象上使用 memcpy

所以好像没有明确叫出UB,但肯定也不是unspecified behavior说的,所以可以得出结论@underscore_d 在对已接受答案的评论中做了:

(...) You can't just say "well, it wasn't explicitly called out as UB, therefore it's defined behaviour!", which is what this thread seems to amount to. N3797 3.9 points 2~3 do not define what memcpy does for non-trivially-copyable objects, so (...) [t]hat's pretty much functionally equivalent to UB in my eyes as both are useless for writing reliable, i.e. portable code

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


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

Link:

首先,请注意,毫无疑问,可变 C/C++ 对象的所有内存都必须是非类型化的、非专用的、可用于任何可变对象的。 (我想全局 const 变量的内存可以假设是类型化的,对于这种微小的极端情况来说,这种超级复杂是没有意义的。)与 Java 不同,C++ 没有类型化的动态分配object: new Class(args) in Java 是一个类型化对象创建:创建一个定义明确的类型的对象,它可能存在于类型化内存中。另一方面,C++ 表达式 new Class(args) 只是一个围绕无类型内存分配的薄类型包装器,等同于 new (operator new(sizeof(Class)) Class(args):对象是在 "neutral memory" 中创建的。改变它意味着改变 C++ 的很大一部分。

禁止某些类型的位复制操作(无论是由 memcpy 还是等效的用户定义的逐字节复制完成)为多态 classes(那些具有虚函数)和其他所谓的 "virtual classes"(不是标准术语),即使用 virtual 关键字的 classes。

多态 classes 的实现可以使用地址的全局关联映射,该映射关联多态对象的地址及其虚函数。我相信这是在第一次迭代 C++ 语言(甚至 "C with classes")的设计过程中认真考虑过的一个选项。多态对象映射可能会使用特殊的 CPU 功能和特殊的关联内存(这些功能不会向 C++ 用户公开)。

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

C++ 标准使缺少全局注册表有点正式 说当你重用一个对象的内存时你可以跳过析构函数调用,只要你不t 取决于该析构函数调用的 "side effects"。 (我相信这意味着 "side effects" 是用户创建的,即析构函数的主体,而不是创建的实现,由实现自动对析构函数完成。)

因为实际上在所有实现中,编译器只使用vptr(指向vtables的指针)隐藏成员,这些隐藏成员将被memcpy正确复制;就好像您对表示多态 class (及其所有隐藏成员)的 C 结构进行了简单的成员明智的复制。按位复制或完整的 C 结构成员复制(完整的 C 结构包括隐藏成员)的行为与构造函数调用完全相同(通过放置 new 完成),因此您所要做的就是让编译器认为您可能称为安置新。如果您执行强外部函数调用(对无法内联且编译器无法检查其实现的函数的调用,如对动态加载代码单元或系统调用中定义的函数的调用),则编译器将假设此类构造函数可能已被它无法检查的代码调用。 因此memcpy这里的行为不是由语言标准定义的,而是由编译器ABI(应用程序二进制接口)定义的。强外部函数调用的行为定义为ABI,而不仅仅是语言标准。对潜在可内联函数的调用由语言定义,因为可以看到它的定义(在编译期间或在 link 时间全局优化期间)。

所以在实践中,给定适当的"compiler fences"(比如调用外部函数,或者只是asm("")),你可以memcpyclasses 只使用虚函数。

当然,当你做一个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++ 中的一个深层缺陷,也许是最深和最麻烦的。]

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

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

无论哪种情况,都完全是对象表示的问题,也就是ABI的问题。

好的,让我们用一个小例子试试你的代码:

#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() 的根本原因:你不知道对象是否包含 pointers/references 到它自己的数据成员,或者取决于以任何其他方式在内存中自己的位置。如果你 memcpy() 这样一个对象,就违反了对象不能在内存中四处移动的基本假设,而一些 类 像 std::string 确实依赖于这个假设。 C++ 标准在(非)平凡可复制对象之间划清界线,以避免涉及更多关于指针和引用的不必要细节。它只为平凡可复制的对象例外,并说:好吧,在这种情况下你是安全的。但是,如果您尝试 memcpy() 任何其他对象,请不要将后果归咎于我。