为什么对于不是TriviallyCopyable的对象,std :: memcpy的行为是未定义的?
从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
本身的行为是未定义的? 为什么标准认为有必要指定?
UPDATE
http://en.cppreference.com/w/cpp/string/byte/memcpy的内容已经被修改,以回应这个post和post的答案。 目前的描述说:
如果对象不是TriviallyCopyable (例如标量,数组,C兼容结构),则行为是不确定的,除非程序不依赖于目标对象(不由
memcpy
运行)的析构函数的效果以及目标对象(已结束,但未由memcpy
启动)通过其他方式启动,如placement-new。
PS
评论者@Cubbi:
@RSahu如果有东西保证UB下游,它呈现整个程序未定义。 但我同意,在这种情况下,似乎有可能绕过UB,并相应地修改相关的参考。
为什么在使用非TriviallyCopyable对象时,
std::memcpy
本身的行为是未定义的?
不是! 但是,一旦将非平凡可复制types的一个对象的基础字节复制到该types的另一个对象中, 目标对象就不是活动的 。 我们通过重新使用它的存储来销毁它,并且没有通过构造函数调用它。
使用目标对象 – 调用其成员函数,访问其数据成员 – 显然是未定义的[basic.life] / 6 ,对于具有自动存储持续时间的目标对象,后续隐式析构函数调用[basic.life] / 4也是如此。 请注意未定义的行为是如何回溯的 。 [intro.execution] / 5:
但是,如果任何这样的执行包含未定义的操作,则本国际标准不要求执行该程序的实现( 即使在第一个未定义的操作之前的操作 )。
如果一个实现发现一个对象已经死了,并且必然会受到未定义的进一步操作的影响,那么它可能会通过改变你的程序语义来作出反应。 从memcpy
电话开始。 一旦我们想到优化者和他们所做的某些假设,这个考虑变得非常实用。
应该指出的是,标准库能够并允许优化某些标准的库algorithm,以便可复制的types,虽然。 std::copy
指向可复制types的指针通常会调用基础字节上的memcpy
。 所以swap
。
因此,只要坚持使用普通的genericsalgorithm,并让编译器做适当的低级优化 – 这首先是发明了一种可复制types的概念:确定某些优化的合法性。 此外,这避免了伤害你的大脑,因为不必担心语言中矛盾和不明确的部分。
因为标准是这样说的。
编译器可能会认为非TriviallyCopyabletypes只能通过复制/移动构造函数/赋值操作符来复制。 这可能是为了优化目的(如果某些数据是私有的,则可以推迟设置,直到发生复制/移动)。
编译器甚至可以自由地执行你的memcpy
调用,让它无所事事 ,或者格式化你的硬盘。 为什么? 因为标准是这样说的。 无所事事肯定比移动快,所以为什么不优化你的memcpy
到同样有效的更快的程序呢?
现在,在实践中,当你只是在不期望它的types的位时,会出现很多问题。 虚拟function表可能没有正确设置。 用于检测泄漏的仪器可能没有正确设置。 对象的身份包括他们的位置完全搞乱你的代码。
真正有趣的是using std::swap; swap(*ePtr1, *ePtr2);
using std::swap; swap(*ePtr1, *ePtr2);
应该可以通过编译器编译到memcpy
,并为其他types定义行为。 如果编译器可以certificate复制只是被复制的位,则可以自由将其更改为memcpy
。 如果你可以写一个更优化的swap
,你可以在相关对象的命名空间中这样做。
构build一个基于memcpy
的swap
会破坏的类很容易:
struct X { int x; int* px; // invariant: always points to x X() : x(), px(&x) {} X(X const& b) : x(bx), px(&x) {} X& operator=(X const& b) { x = bx; return *this; } };
memcpy
这样的对象中断不变。
这与标准文件和stringstream如何实现相似。 stream最终从std::basic_ios
派生,其中包含指向std::basic_streambuf
的指针。 这些stream还包含作为成员(或基类子对象)的特定缓冲区, std::basic_ios
中的指针指向该成员。
C ++并不保证所有types的对象都占用连续的存储字节[intro.object] / 5
可复制或标准布局types(3.9)的对象应占用连续的存储字节。
事实上,通过虚拟基类,您可以在主要实现中创build不连续的对象。 我试图build立一个例子,其中一个对象x
的基类子对象位于x
的起始地址之前 。 为了可视化这个,考虑下面的图表/表格,其中水平轴是地址空间,垂直轴是inheritance级别(级别1从级别0inheritance)。 由dm
标记的字段由类的直接数据成员占用。
L | 00 08 16 - + --------- 1 | DM 0 | DM
使用inheritance时,这是一种常见的内存布局。 但是,虚拟基类子对象的位置不是固定的,因为它可以通过也从相同基类虚拟inheritance的子类重新定位。 这可能导致1级(基类子)对象报告它从地址8开始并且是16个字节大的情况。 如果我们天真地添加这两个数字,我们会认为它占据了地址空间[8,24],即使它实际占用了[0,16]。
如果我们可以创build这样的1级对象,那么我们不能使用memcpy
来复制它: memcpy
将访问不属于这个对象(地址16到24)的内存。 在我的演示中,被clang ++的地址清理器捕获为堆栈缓冲区溢出。
如何构build这样的对象? 通过使用多个虚拟inheritance,我想出了一个具有以下内存布局的对象(虚拟表指针被标记为vp
)。 它由四层inheritance组成:
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)
注意两个强调结束偏移量。
这些答案中的很多都提到, memcpy
可能会破坏类中的不variables,这会在以后导致不确定的行为(在大多数情况下应该足够理性而不冒险),但这似乎并不是你真正要求的。
memcpy
调用本身被认为是未定义行为的一个原因是为编译器提供尽可能多的空间来基于目标平台进行优化。 通过将调用本身设为UB,编译器可以做出奇怪的,依赖于平台的事情。
考虑这个(非常人为和假设的)例子:对于一个特定的硬件平台,可能会有几种不同的内存,有些在不同的操作上比其他的要快。 例如,可能是一种特殊的内存,允许额外的快速内存拷贝。 这个虚拟平台的编译器因此被允许把所有的TriviallyCopyable
types放在这个特殊的内存中,并且实现memcpy
来使用只在这个内存上工作的特殊的硬件指令。
如果要在此平台上的非TriviallyCopyable
对象上使用memcpy
,则memcpy
调用本身可能会出现一些低级的INVALID OPCODE崩溃。
也许不是最有说服力的论点,但重要的是标准并不禁止它 ,这只有通过使用memcpy
调用 UB才是可能的。
memcpy将复制所有的字节,或者在你的情况下交换所有的字节,就好了。 过度热心的编译器可能把“未定义的行为”作为各种恶作剧的借口,但大多数编译器不会这样做。 不过,这是可能的。
但是,复制这些字节后,您复制它们的对象可能不再是一个有效的对象。 简单情况是一个string实现,其中大string分配内存,但小string只是使用string对象的一部分来保存字符,并保持一个指针。 指针将显然指向另一个对象,所以事情将是错误的。 我见过的另一个例子是一个只有极less数情况下才使用的数据类,所以数据被保存在一个数据库中,对象的地址作为关键字。
现在,如果您的实例包含互斥体,例如,我认为移动它可能是一个主要问题。
memcpy
是UB的另一个原因是(除了其他答案中提到的内容 – 稍后可能会破坏不variables),标准很难说明会发生什么 。
对于非平凡的types,这个标准对于如何在内存中放置对象,成员被放置的顺序,vtable指针的位置,填充应该是什么等几乎没有提及。编译器拥有大量的自由在决定这一点。
因此,即使标准希望在这些“安全”情况下允许使用memcpy
,也不可能说明哪些情况是安全的,哪些不是,或者不确切的UB是否会因为不安全的情况而触发。
我想你可能会认为这些影响应该是实现定义的或者没有具体说明的,但是我个人认为这会对平台的细节有深入的了解,给一些在一般情况下的合法性是相当不安全的。
我可以在这里看到的是,对于一些实际的应用程序,C ++标准可能是限制性的,或者说是不够允许的。
正如其他答案中所示, memcpy
对于“复杂”types快速分解,但恕我直言,它实际上应该适用于标准布局types,只要memcpy
不会破坏标准布局types定义的复制操作和析构函数。 (请注意,一个偶数的TC类可以有一个不平凡的构造函数。)该标准只显式调用TCtypeswrt。 这个,但是。
最近的一份报价(N3797):
3.9types
…
2对于一般可复制typesT的任何对象(基类子对象除外),无论对象是否保存Ttypes的有效值,构成对象的基础字节(1.7)都可以复制到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
– 例子]
3对于任何普通可复制typesT,如果指向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
– 例子]
这里的标准讨论了可微复制的types,但是正如上面的@dyp 所观察到的那样,也有标准的布局types ,就我所见,它不一定与Trivially可复制types重叠。
标准说:
1.8 C ++对象模型
(……)
5 (…)可复制或标准布局types(3.9)的对象应占用连续的存储字节。
所以我在这里看到的是:
- 该标准没有提到非Trivially可复制typeswrt。
memcpy
。 (这里已经多次提到过) - 该标准对于占据连续存储的标准布局types具有单独的概念。
- 标准不明确允许或不允许在标准布局的不可 Trivially可复制的对象上使用
memcpy
。
所以它似乎没有明确地被称为UB,但它当然也不是什么被称为不明确的行为 ,所以可以得出结论@underscore_d在接受的答案的评论:
(…)你不能只是说“好吧,它没有被明确地称为UB,因此它是定义的行为!”,这是这个线程似乎相当。 N3797 3.9分2〜3没有定义memcpy为非平凡可复制对象所做的事情,所以(…)[…]在我眼中相当于UB,因为两者对于编写可靠的,即可移植的代码都是无用的
我个人会得出这样的结论,就可移植性而言,它相当于UB(但是这些优化器),但是我认为,通过对具体实现的一些对冲和知识,人们可以避开它。 (只要确保它是值得的麻烦。)
注意:我也认为标准确实应该将标准布局types的语义明确地整合到整个memcpy
混乱中,因为它是一个有效和有用的用来对非Trivially可复制对象进行按位复制的方法,但是这里就不重要了。
链接: 我可以使用memcpy写入多个相邻的标准布局子对象吗?
事实certificate,这个标准并没有说明不是TriviallyCopyable对象的std::memcpy
的行为。 http://en.cppreference.com/w/cpp/string/byte/memcpy的内容已被修改,以回应原来的post和post的答案。; 目前的描述说:
如果对象不是TriviallyCopyable(例如标量,数组,C兼容结构),则行为是不确定的,除非程序不依赖于目标对象(不是由memcpy运行)的析构函数的效果,目标对象(已结束,但未由memcpy启动)通过其他方式启动,如placement-new。