为什么对于不能TriviallyCopyable的对象,std :: memcpy的行为将无法定义?


75

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

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

更新

针对此帖子和该帖子的答案已修改了http://en.cppreference.com/w/cpp/string/byte/memcpy的内容。当前的描述是:

如果对象不是TriviallyCopyable(例如标量,数组,与C兼容的结构),则除非程序不依赖于目标对象的析构函数的影响(不是由memcpy)运行,否则行为是不确定的。目标对象(以结束,但不是以开头memcpy)是通过其他一些方式(例如,新放置)来启动的。

聚苯乙烯

@Cubbi的评论:

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


1
@Columbo,我希望我可以为自己的工作提出这一主张。我们仍然使用VS2008 :)
R Sahu 2015年

3
最近有一篇有趣的论文
TC

2
§3.9/ 3 [basic.types]“对于任何可简单复制的类型 T,如果两个指针T指向不同的T对象,obj1并且obj2如果基类子对象obj1也不obj2是基类子对象(如果组成基础字节obj1被复制到)obj2,则obj2随后应保存与obj1“相同的值。(强调我的)后续样本使用std::memcpy
Mooing Duck 2015年

1
@dyp“我刚刚了解到,在C中,对象没有类型”-标准经常使用术语“ T型对象”。在我看来,用两种语言都没有正确定义对象模型。
MM

1
@dyp如果不声明等价,我看不出该声明如何成为定义。那么,什么是物体呢?
MM

Answers:


45

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

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

显然,使用目标对象(调用其成员函数,访问其数据成员)是未定义的[basic.life] / 6,随后具有自动存储期限的目标对象的隐式析构函数调用[basic.life] / 4也是未定义的。注意未定义的行为是如何追溯的。[intro.execution] / 5:

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

如果某个实现发现某个对象是如何死亡的,并且必须对其进行进一步的未定义操作,则它可能会通过更改程序语义来做出反应。来自memcpy通话开始。一旦我们想到了优化器及其做出的某些假设,这种考虑就变得非常实用。

应该注意的是,尽管标准库能够并且允许为琐碎的可复制类型优化某些标准库算法。std::copy指向普通可复制类型的指针通常会调用memcpy基础字节。也是swap
因此,只需坚持使用常规的通用算法,然后让编译器进行任何适当的低级优化-这部分是首先发明了可微写类型的想法:确定某些优化的合法性。同样,这避免了由于不得不担心语言中相互矛盾且未指定的部分而伤害您的大脑。


4
@dyp好吧,无论如何,对象的生存期在“重新使用或释放​​”它的存储之后结束([basic.life] /1.4)。关于析构函数的那部分是可选的,但是存储是必需的。
2015年

1
在我看来,平凡可复制类型的对象可以具有非平凡的初始化。因此,如果memcpy使用这样的类型结束目标对象的生存期,则它将不会被复活。我认为,这与您的论证不一致(尽管这可能与标准本身不一致)。
dyp

1
(我认为这可能不是完全正确指定的,或者可能是标准中缺少重要信息或很难推断出重要信息。例如,“重用存储”是什么意思?)
dyp

1
@dyp重用存储<=>通过char或unsigned char类型的glvalue直接修改对象表示形式的一个或多个字节吗?我不知道。指定无处,天哪,
哥伦布

1
好吧,经过更多的思考并深入研究std-discussion列表:任何对象的生存期都在重用其存储时结束(同意,但是恕我直言,在3.8p1中更清楚)。重用可能未指定,但是我想通过over的覆盖memcpy旨在算作重用。初始化的琐碎性(或虚空性)是初始化的属性,而不是类型的属性。当时没有通过目标对象的ctor进行初始化memcpy,因此,该初始化始终是空虚的
dyp 2015年

25

构造一个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
OTOH,我想很容易指定memcpy在这种情况下只是打破了不变性,但严格定义了效果(递归memcpys成员,直到它们可以被简单复制)。
dyp

@dyp:我不喜欢这样,因为如果认为定义明确,则破坏封装似乎太容易了。
凯文(Kevin)

1
@dyp可能导致性能怪胎“无意间”复制不可复制的对象。
Maxim Egorushkin 2015年

23

因为标准是这样说的。

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

编译器甚至可以自由地接听您的memcpy电话,使其无所事事,或格式化硬盘。为什么?因为标准是这样说的。而且,无所事事肯定比四处移动要快,所以为什么不优化您的memcpy来同样有效呢?

现在,在实践中,当您只对不需要的类型的位进行四处循环时,可能会发生许多问题。虚拟功能表可能未正确设置。用于检测泄漏的仪器可能未正确安装。身份包括其位置的对象将完全被您的代码弄乱。

真正有趣的部分是,对于编译器可复制的类型,using std::swap; swap(*ePtr1, *ePtr2);应该可以将其编译memcpy为,对于其他类型,则应定义行为。如果编译器可以证明副本只是被复制的位,则可以将其更改为memcpy。而且,如果可以编写更优化的代码swap,则可以在相关对象的名称空间中执行。


2
@TC如果您memcpy从一个类型的对象T到另一个非chars数组的对象,那么目标对象的dtor会不会导致UB?
dyp

3
@dyp当然可以,除非您new在此期间在其中放置了新对象。我memcpy的理解是,“进入某种东西就算是“重用存储”,因此它结束了以前存储的生命(并且由于没有dtor调用,因此如果您依赖于dtor产生的副作用,您将拥有UB),但是不会开始新对象的生命周期,除非稍后在其中T构造了实际对象,否则您将在隐式dtor调用中获得UB 。
TC

3
@RSahu最简单的情况是编译器将标识注入对象,这是合法的。例如,将迭代器双向链接到它们来自的容器,std以便您的代码尽早捕获无效的迭代器使用,而不是通过覆盖内存等(一种插桩的迭代器)。
Yakk-Adam Nevraumont

2
@MooingDuck,这是memcpy在这些对象上使用会导致下游问题的非常合理的原因。那是否足以说明memcpy此类对象的行为未定义?
R Sahu 2015年

2
@Cubbi我再次改写它。如果您破坏了动态存储持续时间的某些东西,memcpy然后才泄漏它,那么即使您不在那里创建新的对象,也应该对行为进行明确定义(如果您不依赖于dtor的影响),因为没有会导致UB的隐式dtor调用。
TC

16

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

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

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

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

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

如果我们可以创建这样的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 :: report 0处的报告; 数据偏移16; 天真地抵消48
l2 :: report 0处的报告; 数据在偏移量8处;天真地抵消40
l1 :: report在偏移量32处;数据偏移40; 天真地抵消56
l0 :: report在偏移量24处;数据在偏移量24处; 天真地抵消32
完整的对象占用[0x9f0,0xa20)
从[0xa10,0xa28)复制到[0xa20,0xa38)

注意两个强调的末端偏移量。


那是一个很好的答案。感谢您的深入解释和演示代码。
R Sahu 2015年

只有子对象可以是不连续的。一个完整的对象是连续的。
curiousguy

@curiousguy这是否由标准保证?填充字节呢?一个由三页组成的对象(中间一页不可访问)会不合规吗?
dyp '18

@dyp持续不重要!并非所有字节都重要。字节无关紧要...无关紧要。因此,您可以说表示中存在“空洞”,但是表示所占用的内存是sizeof(T)从完整对象的地址开始的字节内部字节,这就是我的观点。您可以在足够大且对齐的存储中拥有一个非抽象类类型的对象。在语言语义级别和内存访问级别,这是一个强烈的要求:所有分配的内存都是等效的。存储可以重复使用。
curiousguy18年

在实践中,只有那些始终为const的全局或静态const对象(没有可变成员并且没有对c / dtor进行修改)可能会在实践中被特殊对待,因为它们可以放在只读内存中,并且可以放在“其他答案中提出的“特殊”记忆。但是其他对象在内存中不是恒定的,C ++给出的自由度意味着不键入内存:所有存储用户定义对象的非恒定内存都是通用的。
curiousguy18年

6

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

为什么将memcpy调用本身视为未定义行为的原因之一是为编译器提供了尽可能多的空间,使其可以基于目标平台进行优化。通过使调用本身为UB,编译器可以执行与平台相关的奇怪操作。

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

如果要在此平台memcpy上的非TriviallyCopyable对象上使用,则memcpy调用本身可能会发生一些低级的INVALID OPCODE崩溃。

也许这不是最令人信服的论点,但关键是该标准并未禁止它,只有通过使memcpy 调用UB来实现。


3
感谢您解决核心问题。有趣的是,被高度评价的答案谈论的是下游影响,而不是核心问题。
R Sahu 2015年

可能有几种不同类型的内存”您是否在考虑特定的CPU?
curiousguy18年

可能有几种不同的内存”在C / C ++中?只有一种malloc,一种new
curiousguy18年

例如,编译器可以选择将const全局对象放入只读内存中。那是一个特殊的内存优化的例子,它并不牵强。这个特定的示例更具假设性和人为设计,但从理论上讲,如果需要,编译器可以相同的方式将全局不可平凡的副本放置在某种不可复制的内存中。
CAdaker

4

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

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

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


是的,但这是用户代码问题,而不是核心语言问题。
curiousguy18年

2

memcpyUB的另一个原因(除了在其他答案中提到的以外-稍后可能会破坏不变式)是,标准很难确切说明将要发生的情况

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

结果,即使标准要允许 memcpy在这些“安全”情况下,也将无法说明哪些情况是安全的,哪些情况不是安全的,或者何时会在不安全的情况下确切触发真正的UB。

我想您可能会争辩说,效果应该是实现定义的或未指定的,但是我个人认为这既会深入挖掘平台的细节,又会给一般情况下的内容提供太多的合法性是相当不安全的。


1
我有说,使用memcpy来写没问题的,以这样的对象调用UB,因为一个对象可以有哪些是不断变化的领域,但同时也会,如果他们的方式编译器不知道正在改变发生不好的事情。鉴于T * P,是有原因的任何原因memcpy(buffer, p, sizeof (T)),在这里buffer是一个char[sizeof (T)];应该被允许做的比写的其他一些字节到缓冲区什么?
超级猫

vptr只是另一个隐藏的成员(或MI的许多此类成员)。如果您将一个完整的对象复制到另一个相同类型的对象上,则它们的位置无关紧要。
curiousguy18年

2

首先,请注意,毫无疑问,可变C / C ++对象的所有内存都必须是无类型的,非专用的,可用于任何可变对象的。(我猜想可以假设为全局const变量的内存进行类型化,对于这种微小的转折情况,没有超级复杂的意义。)与Java不同,C ++没有动态对象的类型化分配new Class(args)在Java中是类型化对象的创建:创建一个定义明确的对象,它可能存在于键入的内存中。另一方面,C ++表达式new Class(args)只是无类型内存分配的瘦类型包装器,等效于new (operator new(sizeof(Class)) Class(args):对象在“中性内存”中创建。进行更改将意味着更改C ++的很大一部分。

禁止memcpy某种类型的位复制操作(无论是由位复制完成还是由用户定义的逐字节复制操作)为多态类(具有虚拟功能的类)和其他所谓的“虚拟类”(非虚拟类)实现提供了很大的自由度。标准术语),即使用该virtual关键字的类。

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

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

C ++标准说缺少全局注册表是某种正式的说法,只要您不依赖于析构函数调用的“副作用”,就可以在重用对象的内存时跳过析构函数调用。(我认为这意味着“副作用”是用户创建的,即析构函数的主体,而不是由实现创建的实现,而是由实现对析构函数自动执行的。)

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

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

当然,当执行a时,语言语义必须允许您进行新的放置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问题。


1
我读了您的回答,但无法弄清您要说的内容的实质。
R Sahu

tl; dr:memcpy在实践中,您可以在ABI暗示可以的情况下使用多态类,因此它本质上取决于实现。无论如何,您都需要使用编译器屏障来隐藏您正在做的事情(合理的可否认性),并且您仍然必须尊重语言的语义(不要尝试更改现有对象的类型)。
curiousguy18年

这是不可TriviallyCopyable的对象类型的子集。只是要确保您的答案打算解决memcpy仅针对多态对象类型的行为。
R Sahu

我明确讨论了虚拟类,它是多态类的集合。我认为禁止memcpy某些类型的历史原因是虚函数的实现。对于非虚拟类型,我不知道!
curiousguy18年

0

我在这里可以看到的是-在某些实际应用中-C ++标准可能是限制性的,或者是不够许可的。

如其他答案所示,memcpy对于“复杂”类型可以快速分解,但是恕我直言,它实际上应该适用于标准布局类型,只要memcpy它不会破坏标准布局类型的已定义复制操作和析构函数所做的工作即可。(请注意,甚至TC类也可以具有非平凡的构造函数。)该标准仅显式地调用wrt的TC类型。但是。

最近的报价草稿(N3797):

3.9类型

...

2对于平凡可复制的类型T的任何对象(基类子对象除外),无论该对象是否持有类型T的有效值,都可以将构成对象的基础字节(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对于任何普通可复制类型T,如果指向T的两个指针指向不同的T对象obj1和obj2,则将组成obj1的基础字节(1.7)复制到obj2和obj2,其中obj1和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

—末例]

这里的标准讨论的琐碎可复制类型,但是正如上面的@dyp所观察到的,据我所知,还有一些标准的布局类型不一定与琐碎可复制类型重叠。

该标准说:

1.8 C ++对象模型

(...)

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

所以我在这里看到的是:

  • 该标准对非普通复制类型wrt没有任何规定。memcpy。(如这里已经多次提到的)
  • 该标准对占用连续存储的标准布局类型有单独的概念。
  • 该标准既不明确也不允许memcpy在“普通布局”中的“不可复制”对象上使用。

因此,它似乎没有被明确地称为UB,但它当然也不是未指定的行为,因此可以得出结论:@underscore_d在接受的答案的注释中做了什么:

(...)您不能只说“好吧,它没有明确地称为UB,因此它是定义的行为!”,这就是该线程的含义。N3797 3.9点2〜3并未定义memcpy对不可复制对象的作用,所以(t)在我眼中,帽子在功能上等效于UB,因为这两者对于编写可靠的可移植代码都没有用

我个人会得出结论,就可移植性而言,它相当于UB(哦,那些优化程序),但是我认为只要有了一些对冲和对具体实现的了解,人们就可以摆脱它。(只需确保值得一试)。


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

链接:我可以使用memcpy写入多个相邻的Standard Layout子对象吗?


逻辑上说,类型memcpy必须具备TC状态,因为此类对象必须具有默认的复制/移动构造函数和分配操作,这些操作定义为简单的按字节复制,例如memcpy。如果我说我的类型是memcpy可以的,但是具有非默认副本,则表示我自己与我与编译器的合同相抵触,后者说对于TC类型,只有字节才重要。即使我的自定义副本构造函数/分配只是做了按字节复制和增加了一个诊断消息,++SAstatic柜台或东西-这意味着我期望编译器来分析我的代码和证明它不惹字节表示。
underscore_d

SL类型是连续的,但可以具有用户提供的复制/移动控制器/分配操作。如果按字节证明所有用户操作等效,memcpy将要求编译器对每种类型进行不切实际/不合理的静态分析。我没有记录下来,这是动机,但似乎令人信服。但是,如果我们相信cppreference - Standard layout types are useful for communicating with code written in other programming languages-是他们没有说语言能够采取的副本以确定的方式多大用处?我猜我们只有在安全地在C ++端分配之后才能传递指针。
underscore_d

@underscore_d-我不同意要求这样做是合乎逻辑的。仅需确保TC确保memcpy在语义上等效于逻辑对象副本。OP示例显示按位交换两个对象是不执行逻辑复制的示例,恕我直言。
马丁·巴

而且,编译器无需检查任何内容。如果memcpy弄乱了对象状态,那么您不应该使用memcpy!我认为std应该明确允许的是与SL类型的OP完全按位交换,即使它们不是TC。当然,在某些情况下,它会崩溃(自引用对象等),但这绝不是让它陷入困境的原因。
Martin Ba

好吧,可以肯定的是,也许他们会说:“您可以根据需要复制此文件,并且它被定义为具有相同的状态,但是这是否安全-例如,不会引起资源的病理性共享-取决于您”。不知道我是否愿意。但是要同意,无论决定什么,都应该做出决定。像标准本这样的大多数情况都不是很具体,使人们对他们是否可以安全使用它感到不安,而像我这样的人却对这样的主题感到不安,有人用这些概念杂技在人的嘴上说出话来。留下空白的标准;-)
underscore_d

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()任何其他物体,请不要怪我会带来什么后果。

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.