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

R S*_*ahu 67 c++ memcpy object-lifetime language-lawyer c++11

来自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);
}
Run Code Online (Sandbox Code Playgroud)

从来没有任何问题.

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

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

UPDATE

http://en.cppreference.com/w/cpp/string/byte/memcpy的内容已经过修改,以回应这篇文章和帖子的答案.目前的描述说:

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

PS

@Cubbi的评论:

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

Col*_*mbo 38

std::memcpy当与非TriviallyCopyable对象一起使用时,为什么它本身的行为是未定义的?

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

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

但是,如果任何此类执行包含未定义的操作,则此国际标准不要求使用该输入执行该程序的实现(甚至不考虑第一个未定义操作之前的操作).

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

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

  • @dyp好吧,在任何情况下,对象的生命周期在其"重用或释放"([basic.life] /1.4)之后结束.关于析构函数的部分是可选的,但存储是必需的. (4认同)

Max*_*kin 23

构建一个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; }
};
Run Code Online (Sandbox Code Playgroud)

memcpy这样的物体打破了不变的.

GNU C++ 11 std::string与短字符串完全相同.

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

  • OTOH,我猜想很容易指出`memcpy`在这种情况下只是打破了不变量,但是效果是严格定义的(递归地`memcpy是成员,直到它们可以轻易地复制). (3认同)

Yak*_*ont 22

因为标准是这样说的.

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

编译器甚至可以随意接听您的memcpy电话并使其无效,或格式化您的硬盘.为什么?因为标准是这样说的.无所事事肯定比移动位更快,所以为什么不优化你memcpy的同等有效的更快的程序呢?

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

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

  • @dyp当然,除非你在同一时间放置一个新对象.我的阅读是"memcpy"到某些东西被称为"重用存储",所以它结束了以前那里的生命周期(因为没有dtor调用,如果你依赖于由此产生的副作用,你有UB dtor),但是没有开始新对象的生命周期,并且稍后在隐式dtor调用时获得UB,除非在那里同时构造实际的"T". (3认同)
  • @RSahu最简单的情况是编译器将标识注入对象,这是合法的.举个例子,将迭代器直接链接到它们来自`std`的容器,这样你的代码就可以捕获无效的迭代器,而不是通过覆盖内存等(一种检测的迭代器). (3认同)
  • @TC如果你从一个`T`类型的对象`memcpy`到另一个不是`char`s数组'的对象,目标对象的dtor不会导致UB吗? (2认同)
  • @MooingDuck,这些是非常有效的原因,为什么在这些对象上使用`memcpy`会导致下游问题.是否有足够的理由说`memcpy`的行为未定义为此类对象? (2认同)
  • @Cubbi [我再次改写它.](http://en.cppreference.com/mwiki/index.php?title=cpp/string/byte/memcpy&diff=77966&oldid=77952)如果你破坏了动态存储持续时间的东西` memcpy`然后只是泄漏它,行为应该是明确定义的(如果你不依赖于dtor的影响),即使你没有在那里创建一个新对象,因为没有隐含的dtor调用会导致UB. (2认同)

dyp*_*dyp 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;
};
Run Code Online (Sandbox Code Playgroud)

我们可以生成堆栈缓冲区溢出,如下所示:

l3  o;
l1& so = o;

l1 t;
std::memcpy(&t, &so, sizeof(t));
Run Code Online (Sandbox Code Playgroud)

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

#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));
}
Run Code Online (Sandbox Code Playgroud)

现场演示

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

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)

注意两个强调的结束偏移.


CAd*_*ker 5

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

memcpy调用本身被认为是未定义行为的一个原因是为编译器提供尽可能多的空间以基于目标平台进行优化.通过将调用本身设置为UB,允许编译器执行奇怪的,与平台相关的事情.

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

如果你使用memcpy的非TriviallyCopyable在这个平台上的对象,有可能是一些低级别的操作码无效坠毁memcpy调用本身.

也许并非最有说服力的论点,但重点是标准并不禁止它,这只能通过memcpy 拨打 UB来实现.

  • 感谢您解决核心问题.有趣的是,高度赞成的答案谈论的是下游效应,而不是核心问题. (2认同)