如果对象不是TriviallyCopyable,为什么std::memcpy的行为会变得未定义?

80

来自http://zh.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。


1
@Columbo,我希望我能对我的工作做出这样的声明。我们仍在使用VS2008 :) - R Sahu
3
有一份有趣的最近论文 - T.C.
2
§3.9/3 [basic.types] “对于任何平凡可复制类型 T,如果两个指向不同 T 对象 obj1obj2 的指针,其中 obj1obj2 都不是基类子对象,如果组成 obj1 的底层字节被复制到 obj2 中,则 obj2 随后将持有与 obj1 相同的值”。(强调我的)下面的示例使用了 std::memcpy - Mooing Duck
1
@dyp “我刚学到在C语言中,对象没有类型” - 标准经常使用“类型为T的对象”这个术语。看起来对象模型在两种语言中都没有得到很好的定义。 - M.M
1
@dyp 我不明白如果它没有陈述一个等式,那么这个语句怎么能成为一个定义。那么,什么是对象呢? - M.M
显示剩余20条评论
10个回答

50
为什么对于非TriviallyCopyable对象,使用std::memcpy函数的行为本身就是未定义的?
并不是这样!但是,一旦你将一个非平凡复制类型的底层字节复制到另一个同类型的对象中,目标对象就不再“存活”。我们通过重用其存储空间来销毁了它,并且没有通过构造函数调用使其重新活化。
明显地,使用目标对象——调用它的成员函数、访问它的数据成员——是未定义的,以及针对具有自动存储期限的目标对象的后续隐式析构函数调用也是未定义的。
需要注意的是标准库能够和允许优化某些TriviallyCopyable类型的特定标准库算法。指向TriviallyCopyable类型的指针通常会在底层字节上调用memcpy函数,因此在使用普通的通用算法时,让编译器执行适当的低级别优化即可。这部分是TriviallyCopyable类型的概念被发明的原因之一:确定某些优化的合法性。此外,这避免了由于不必要的担忧而伤害你的大脑,这些担忧可能涉及到矛盾和未具体说明的语言部分。

4
在任何情况下,一个对象的生命周期在其存储被“重用或释放”([basic.life]/1.4)后结束。关于析构函数的部分有点可选,但存储部分是必需的。 - Columbo
1
在我看来,一个平凡可复制类型的对象可以有非平凡的初始化。因此,如果memcpy以这种类型结束目标对象的生命周期,它将不会被复活。我认为这与你的论证不一致(尽管这可能是标准本身的不一致)。 - dyp
1
我认为这可能没有完全明确,或者标准中缺少重要信息或者很难推断。例如,“重用存储”是什么意思? - dyp
1
@dyp 重用存储 <=> 通过 char 或 unsigned char 类型的 glvalue 直接修改对象表示中的一个或多个字节?我不知道。它没有被明确指定,该死的。 - Columbo
1
好的,经过更多思考和对 std-discussion 列表的挖掘后:当一个对象的存储被重新使用时,它的生命周期结束(同意,但在 3.8p1 中这更清晰)。重用是 可能未明确说明,但我认为通过 memcpy 进行覆盖被认为是重用。初始化的平凡性(或空泛)是初始化的属性,而不是类型的属性。当 memcpy 时没有目标对象的构造函数的初始化,因此初始化始终是空泛的。 - dyp
显示剩余10条评论

29

构建一个使memcpy基础的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; }
};

memcpy 对于这样的对象进行复制会破坏不变式。

GNU C++11 的 std::string 对于短字符串正是如此实现的。

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


3
另一方面,我猜测在这种情况下很容易指定 memcpy 简单地打破了不变量,但效果是严格定义的(递归地 memcpy 成员,直到它们可以被简单复制为止)。 - dyp
@dyp:我不喜欢这个,因为如果这被认为是明确定义的话,似乎很容易破坏封装性。 - Kevin
1
@dyp 这可能会导致注重性能的程序员“无意中”复制不可复制的对象。 - Maxim Egorushkin

25

因为标准规定如此。

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

编译器甚至可以接管您的memcpy调用并使其什么也不做,或格式化您的硬盘。为什么?因为标准规定如此。而不做任何操作肯定比移动位更快,所以为什么不将memcpy优化为同样有效但更快的程序呢?

现实中,当您在不期望这种操作的类型中简单地复制位时,会出现许多问题。虚函数表可能设置不正确。用于检测泄漏的工具可能设置不正确。包括它们的位置的对象身份被您的代码完全搞乱。

真正有趣的部分是,using std::swap; swap(*ePtr1, *ePtr2);应该能够通过编译器编译成memcpy,用于处理Trivially Copyable类型,对于其他类型则是定义良好的行为。如果编译器可以证明复制只是位被复制,那么它就可以将其更改为memcpy。如果您可以编写更优化的swap,则可以在相关对象的命名空间中这样做。


2
如果您从一个类型为 T 的对象复制到另一个不是 char 数组的对象,那么目标对象的析构函数是否会导致未定义行为? - dyp
3
可以的,除非在此期间您通过 placement new 放置了一个新对象。我的理解是,使用 memcpy 复制到某个地方算作“重用存储”,因此它会结束先前存在的对象的生命周期(由于没有 dtor 调用,如果您依赖于 dtor 产生的副作用,则会出现 UB),但不会开始新对象的生命周期,除非在此期间实际上构造了一个 T 对象,否则稍后在隐式 dtor 调用时会产生 UB。 - T.C.
3
最简单的情况是编译器将标识注入到对象中,这是合法的。例如,在std中双向链接迭代器和它们所属的容器,以便您的代码可以尽早捕获已失效迭代器的使用,而不是通过覆盖内存或类似方式来检测(一种带有工具的迭代器)。 - Yakk - Adam Nevraumont
2
@MooingDuck,这些都是非常有效的理由,说明在这些对象上使用 memcpy 将会在下游引起问题。这个理由足够说明对于这样的对象,memcpy 的行为是未定义的吗? - R Sahu
2
如果您使用memcpy覆盖动态存储期的某些内容,然后仅泄漏它,即使您没有在那里创建新对象,行为也应该是明确定义的(如果您不依赖于dtor的效果),因为没有隐式的dtor调用会导致UB。@Cubbi [我再次重新表述了一下。] (http://en.cppreference.com/mwiki/index.php?title=cpp/string/byte/memcpy&diff=77966&oldid=77952) - T.C.
显示剩余15条评论

18

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

演示现场

样本输出(为避免垂直滚动而缩写):

l3::报告位于偏移量 0; 数据位于偏移量 16; 简单地到达偏移量 48
l2::报告位于偏移量 0; 数据位于偏移量 8; 简单地到达偏移量 40
l1::报告位于偏移量 32; 数据位于偏移量 40; 简单地到达偏移量 56
l0::报告位于偏移量 24; 数据位于偏移量 24; 简单地到达偏移量 32
完整对象占用 [0x9f0,0xa20)
从 [0xa10,0xa28) 复制到 [0xa20,0xa38)

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


这是一个很棒的答案。感谢您提供深入的解释和演示代码。 - R Sahu
@curiousguy 这是否由标准保证?填充字节呢?一个由三页组成的对象,中间一页不可访问,是否不符合标准? - dyp
@dyp 并不是连续的重要!并非所有字节都很重要。不重要的字节...就不重要。所以你可以说在表示中有“空洞”,但表示占用的内存位于完整对象地址开始的 sizeof(T) 字节内,这是我的观点。 你可以拥有一个非抽象类类型的对象,其存储足够大且对齐。 这是语言语义级别和内存访问级别的强要求:所有分配的内存都是等效的。存储可以被重复使用。 - curiousguy
只有全局或静态的常量对象(没有可变成员和在构造函数/析构函数中不进行修改)在实践中可能会被特殊处理,因为它们可以放置在只读内存中,并且可以像其他答案中提出的那样放置在“特殊”内存中。但是其他对象在内存中并不是常量,C++所赋予的自由意味着内存没有类型:所有存储用户定义对象的非常量内存都是通用的。 - curiousguy
“一个由三个页面组成的对象,其中中间一个页面无法访问,是否不符合规定?” 我支持使用用户定义的对象布局进行扩展:布局将被描述为一组约束条件,编译器必须遵守这些条件或退出。这将使得某些接口类的ABI兼容性在源代码中非常明确。因此,您应该能够使用这种假设的注释创建这样一个愚蠢的布局。您可以映射一个区域,然后通过限制中间页面的访问来打洞。您可以适配一个对象(恰好有大的填充物)。 - curiousguy
显示剩余5条评论

6
许多答案提到,memcpy可能会破坏类中的不变量,并在之后导致未定义的行为(在大多数情况下应该足以理由),但这似乎不是你真正想问的问题。
memcpy本身视为未定义的行为的一个原因是为了给编译器在目标平台上进行基于优化的尽可能多的空间。通过使调用本身成为UB,编译器被允许做一些奇怪的、与平台相关的事情。
考虑这个(非常牵强附会和假设性的)例子:对于某个硬件平台,可能有几种不同种类的内存,其中一些对于不同操作比其他内存更快。例如,可能有一种特殊的内存可以允许超快速的内存复制。这个(虚构的)平台的编译器因此允许将所有的 TriviallyCopyable类型放入这个特殊的内存中,并实现 memcpy 来使用仅适用于这种内存的特殊硬件指令。
如果你在这个平台上对非 TriviallyCopyable对象使用memcpy,可能会在memcpy调用本身中出现一些底层的无效操作码崩溃。
也许这不是最有说服力的论点,但关键是标准没有禁止这样做,这只有通过使 memcpy 调用成为UB才可能实现。

3
感谢您回答核心问题。有趣的是,得到高票的答案谈论了下游影响,但没有回答核心问题。 - R Sahu
“可能有几种不同类型的内存。” 你有特定的CPU想法吗? - curiousguy
在C/C++中,可能会有几种不同类型的内存。但是mallocnew只有一种类型。 - curiousguy
编译器可以选择将const全局对象放置在只读内存中,这是一种特殊的内存优化示例,不是牵强附会。这个特定的示例更具假设性和人为性,但理论上编译器也可以以同样的方式将全局的不可复制对象放置在某种非内存可复制的内存中。 - CAdaker

4

memcpy函数可以完美地复制所有字节,或者在您的情况下交换所有字节。过于热心的编译器可能会将"未定义行为"看作是借口来进行各种恶意操作,但大多数编译器不会这样做。尽管如此,这种情况仍然有可能发生。

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

现在,如果您的实例包含互斥锁,移动它可能会成为一个重大问题。


是的,但那是一个用户代码问题,而不是核心语言问题。 - curiousguy

2
首先需要注意的是,所有可变C/C++对象的内存都必须是无类型、无特化的,可用于任何可变对象。 (我猜想全局const变量的内存可能可以被定义类型,但对于这种微小的特殊情况来说,这样超级复杂没有意义。)与Java不同,C++没有动态对象的类型分配:在Java中,new Class(args)是一个有类型的对象创建:创建一个明确定义类型的对象,该对象可能存在于有类型的内存中。另一方面,C++表达式new Class(args)只是一个薄的类型包装器,围绕着无类型内存分配等效,相当于new (operator new(sizeof(Class)) Class(args):对象在“中性内存”中创建。改变这个将意味着改变C++的很大一部分。
禁止位拷贝操作(无论是由memcpy还是等价的用户定义的逐字节拷贝执行的)对于多态类(具有虚函数的类)和其他所谓的“虚类”(非标准术语)实现来说给了很大自由度。多态类的实现可以使用全局关联映射地址,将多态对象的地址与其虚函数相关联。我相信这是在设计第一次迭代C++语言时认真考虑过的一个选项(甚至是“带类的C”)。该多态对象映射表可能使用特殊的CPU功能和特殊的关联内存(这些功能不对C++用户公开)。
当然,我们知道所有实际的虚函数实现都使用vtable(描述类的所有动态方面的常量记录)并在每个多态基类子对象中放置vptr(vtable指针),因为这种方法非常简单实用(至少对于最简单的情况)和高效。除了调试模式之外,任何现实世界的实现都没有多态对象的全局注册表(我不知道这样的调试模式)。
C++标准通过说可以跳过析构函数调用,当您重用对象的内存时,只要您不依赖于那个析构函数调用的“副作用”。 (我相信这意味着“副作用”是由用户创建的,即析构函数的主体,而不是由实现自动执行的析构函数。)

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

因此,在实践中,通过适当的“编译器栅栏”(例如对外部函数的调用或只是asm("")),您可以memcpy仅使用虚函数的类。

当您进行memcpy时,当然需要被语言语义允许这样做placement new:您不能随意重新定义现有对象的动态类型并假装您并没有破坏旧对象。如果您有一个非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使用地址偏移量来定位这些基本子对象;它们仅取决于最派生对象的类型,例如final overriders和typeid,并且因此可以存储在vtable中。在这些实现中,memcpy将按照ABI保证工作(具有上述更改现有对象类型的限制)。
无论哪种情况,这完全是对象表示问题,即ABI问题。

1
我看了你的答案,但是无法理解你试图表达的实质。 - R Sahu
简而言之:在实践中,您可以在多态类上使用memcpy,其中ABI意味着您可以这样做,因此它本质上取决于实现。无论如何,您需要使用编译器屏障来隐藏您正在进行的操作(可信否认),并且仍必须尊重语言语义(不尝试更改现有对象的类型)。 - curiousguy
这是一些非TriviallyCopyable对象类型的子集。只想确保您的答案仅针对多态对象类型处理memcpy函数的行为。 - R Sahu
我明确讨论虚拟类,它是多态类的超集。我认为禁止某些类型使用 memcpy 的历史原因是虚函数的实现。对于非虚拟类型,我不知道! - curiousguy

2
除了其他答案提到的可能会破坏不变式之外,memcpy 是 UB 的另一个原因是标准很难确切地说出会发生什么。
对于非平凡类型,标准几乎没有说明对象在内存中的布局方式、成员放置的顺序、虚函数表指针的位置、填充等。编译器在决定这些方面有很大的自由度。
因此,即使标准想要在这些“安全”情况下允许使用 memcpy,也无法说明哪些情况是安全的,哪些情况不安全,并且在何时真正的 UB 将被触发以处理不安全的情况。
我认为你可以认为效果应该是实现定义的或未指定的,但我个人认为这样做既过于深入平台特定细节,又过于授予一些通常情况下相当不安全的东西太多的合法性。

1
我毫不犹豫地表示,使用memcpy向这样的对象写入数据会引发未定义行为,因为一个对象可能有一些字段是不断变化的,但如果以编译器不知道的方式进行更改,则会导致不良后果。给定T *p,是否有任何理由允许memcpy(buffer, p, sizeof(T))(其中bufferchar[sizeof(T)])除了将一些字节写入缓冲区之外做任何其他事情? - supercat
vptr只是另一个隐藏成员(或多个这样的成员用于MI)。如果您将完整对象复制到相同类型的另一个对象上,则它们所在的位置并不重要。 - curiousguy

2

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

#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自己的内部缓冲区,因此析构函数推断该内存必须已经在堆上分配,并尝试删除它。在我的机器上的结果是程序简单地崩溃,但是C ++标准不会关心粉红色大象是否出现。行为是完全未定义的。
这就是为什么不应该在非平凡可复制对象上使用memcpy()的根本原因:您不知道对象是否包含对其自己的数据成员的指针/引用,或以任何其他方式依赖于其自己的内存位置。如果您对这样的对象使用memcpy(),则违反了对象不能在内存中移动的基本假设,而某些类(如std :: string)确实依赖于此假设。 C ++标准在(非)平凡可复制对象之间划定了界限,以避免涉及指针和引用的更多不必要的细节。它只对平凡可复制对象做出例外,并表示:好吧,在这种情况下,您是安全的。但是,如果您尝试memcpy()任何其他对象,则不要归咎于我造成的后果。

0
我所能理解的是,在某些实际应用中,C++标准可能过于严格,或者说不够允许。
正如其他答案所示,对于“复杂”类型,memcpy很快就会出现问题,但在我看来,只要memcpy不破坏标准布局类型的定义的复制操作和析构函数,它实际上应该适用于标准布局类型。(请注意,即使是TC类也可以具有非平凡的构造函数。)然而,标准仅明确指出了TC类型。
最近的草案引用(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 ]

这里的标准讨论了可平凡复制类型,但正如@dyp所观察到的那样,还有标准布局类型,据我所见,它们不一定与可平凡复制类型重叠。
标准说:

1.8 C++对象模型

(...)

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

所以我在这里看到的是:
  • 标准在非平凡可复制类型方面对于memcpy没有任何规定。(正如此前已经多次提到的)
  • 标准为占用连续存储空间的标准布局类型定义了一个单独的概念。
  • 标准既不明确允许也不禁止在非平凡可复制的标准布局对象上使用memcpy

因此,它似乎并没有被明确指出为UB,但它当然也不是所谓的未指定行为,因此可以得出@underscore_d在接受答案的评论中所做的结论:

(...) 你不能只说“好吧,它没有被明确指出为UB,因此它是定义良好的行为!”,这就是这个线程似乎要达到的目的。N3797 3.9点2~3没有定义非平凡可复制对象的memcpy操作,因此(...) [t]在我看来,这在功能上与UB基本等效,因为两者都无法编写可靠的、即可移植的代码。

我个人认为,就可移植性而言,这相当于未定义行为(哦,那些优化器),但我认为只要有一些避险措施和对具体实现的了解,就可以逃脱这种情况。(只要确保这样做值得麻烦。)

附注:我认为标准应该明确地将标准布局类型语义纳入整个memcpy混乱中,因为按位复制非平凡可复制对象是一种有效且有用的用例,但这与本文无关。

链接:我可以使用memcpy写入多个相邻的标准布局子对象吗?


对于一个类型来说,需要有 TC 状态才能被视为可 memcpy。因为这些对象必须具有默认的复制/移动构造函数和赋值操作符,这些操作都是通过简单的字节拷贝(如 memcpy)来定义的。如果我声明我的类型是可 memcpy 的,但实际上它具有非默认的复制构造函数,那么我就会与编译器的契约相矛盾。因为对于 TC 类型来说,只有字节才是重要的。即使我的自定义复制构造函数/赋值操作符只是进行了字节级别的拷贝,并添加了一条诊断消息、增加了一个 static 计数器或其他类似的操作,这也意味着我希望编译器分析我的代码并证明它不会影响字节表示。 - underscore_d
SL类型是连续的,但可以具有用户提供的复制/移动构造函数/赋值操作。证明所有用户操作按字节等效于memcpy将要求编译器为每种类型进行不现实/不公平的大量静态分析。我没有记录这是动机,但它似乎很有说服力。但是,如果我们相信cppreference -“标准布局类型对于与其他编程语言编写的代码通信很有用”-如果这些语言不能以定义的方式进行复制,它们是否非常有用?我猜我们只能在C++端安全分配后传递指针。 - underscore_d
@underscore_d - 我不同意这是必要的逻辑。TC仅需要确保memcpy在语义上等同于逻辑对象复制。我认为,OP示例表明,交换两个对象的位是一个没有执行逻辑复制的示例。 - Martin Ba
编译器没有检查任何要求。如果memcpy破坏了对象状态,那么你就不应该使用memcpy!我认为std应该明确允许像OP和SL类型一样进行位交换,即使它们不是TC。当然,会有一些情况会出现问题(自引用对象等),但这并不是将其留在悬浮状态的理由。 - Martin Ba
当然,也许他们可以说:“如果你想复制它,并且它被定义为具有相同的状态,但是是否安全——例如不会导致资源的病理共享——就取决于你。” 不确定我是否会支持这一点。但是同意,无论做出什么决定……都应该做出决定。大多数类似于标准不具体的情况都会让人们希望拥有能力,对于是否安全使用它感到不安,像我这样阅读这些线程的人则对某些人用概念上的杂技来替标准说话感到不安,因为标准留下了空白;-) - underscore_d

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接