我可以在C++中使用memcpy复制没有指针或虚函数的类吗?

43

假设我有一个类,类似下面的代码:

class MyClass
{
public:
  MyClass();
  int a,b,c;
  double x,y,z;
};

#define  PageSize 1000000

MyClass Array1[PageSize],Array2[PageSize];

如果我的类没有指针或虚拟方法,使用以下代码是否安全?

memcpy(Array1,Array2,PageSize*sizeof(MyClass));
我问这个问题的原因是,我正在处理大量的分页数据集合,如此处所述,性能至关重要,并且memcpy相对于迭代赋值具有显着的性能优势。我认为这应该没问题,因为“this”指针是隐式参数而不是任何存储的内容,但是否还有其他隐藏的问题需要注意吗? 编辑: 根据sharptooths的评论,数据不包括任何句柄或类似的引用信息。
根据Paul R的评论,我已经分析了代码,并且在这种情况下避免使用复制构造函数可以提高大约4.5倍的速度。部分原因是我的模板数组类比给出的简单示例要复杂得多,并且在为不允许浅层复制的类型分配内存时调用放置“new”。这实际上意味着除了复制构造函数之外,还会调用默认构造函数。 第二次编辑: 也许值得指出的是,我完全接受以这种方式使用memcpy是不好的做法,并且在一般情况下应该避免。它被用作高性能模板数组类的一部分,其中包括参数“AllowShallowCopying”,该参数将调用memcpy而不是复制构造函数。这对于类似从数组开始附近删除元素和分页数据进出辅助存储等操作具有重大的性能影响。更好的理论解决方案是将类转换为简单结构,但是考虑到这需要大量重构大型代码库,我不想这样做。

2
在位拷贝数据类型的解释时,你应该小心。这不仅与它是指针还是整数有关,也与所有权语义有关。例如,Win32 API 句柄通常不是指针,但是将它们进行位拷贝通常是错误的。 - sharptooth
1
你是否真的有证据表明正常的复制(迭代和复制构造函数)会产生任何性能损失,还是这只是一个直觉?如果你还没有这样做,我建议你进行分析,因为即使是最简单的复制实现也应该能够将可用的DRAM带宽最大化。 - Paul R
@sharptooth,非常好的观点。在这种情况下,没有句柄或其他对任何动态内容的引用,基本上只是带有属性的三维坐标数据。 - SmacL
1
对于OP的简单示例来说,我的分析显示在GCC 4.4上,memcpy仅快大约2倍。 - smerlin
7
如果这是一个性能关键的领域,那么仅仅2倍的提升仍然非常重要。 - Mr. Boy
显示剩余9条评论
11个回答

19
根据标准,如果程序员没有为一个类提供拷贝构造函数,编译器将合成一个表现为默认成员初始化的构造函数。(12.8.8) 然而,在12.8.1中,标准还说,一个类对象可以通过两种方式进行复制,通过初始化(12.1, 8.5),包括函数参数传递(5.2.2)和函数返回值(6.6.3),以及通过赋值(5.17)。从概念上讲,这两个操作是由拷贝构造函数(12.1)和拷贝赋值运算符(13.5.3)实现的。这里的关键词是"从概念上讲",根据Lippman的说法,这使得编译器设计者有一个“出路”,可以在“平凡”的(12.8.6)隐式定义的拷贝构造函数中不实际执行成员初始化。
因此,在实践中,编译器必须为这些类合成拷贝构造函数,展示出它们像执行成员初始化一样的行为。但如果该类展示了"位拷贝语义"(Lippman,第43页),那么编译器就不必合成拷贝构造函数(可能会导致内联的函数调用),而是执行位拷贝。这种说法显然得到了ARM的支持,但我还没有查过。
使用编译器验证标准兼容性总是个不好的主意,但编译代码并查看生成的汇编似乎可以验证编译器没有在合成的复制构造函数中进行成员逐个初始化,而是进行了一个memcpy
#include <cstdlib>

class MyClass
{
public:
    MyClass(){};
  int a,b,c;
  double x,y,z;
};

int main()
{
    MyClass c;
    MyClass d = c;

    return 0;
}

MyClass d = c;生成的汇编代码是:

000000013F441048  lea         rdi,[d] 
000000013F44104D  lea         rsi,[c] 
000000013F441052  mov         ecx,28h 
000000013F441057  rep movs    byte ptr [rdi],byte ptr [rsi] 

...其中28hMyClasssizeof大小。

这是在MSVC9的Debug模式下编译的。

编辑:

这篇文章的要点如下:

1)只要按位复制会产生与成员逐个复制相同的副作用,标准允许平凡的隐式复制构造函数使用memcpy而不是逐个成员复制。

2)有些编译器实际上使用memcpy而不是合成一个执行成员逐个复制的平凡复制构造函数。


有趣的东西。所以我猜即使有了这个,性能提升也来自于整个数组的单个'rep movs',而不是循环中的上述代码。我想知道其他编译器的汇编输出会是什么样子。这似乎是一个非常明显的优化。 - SmacL
感谢 @john-dibling 提供的 ARM 参考资料。 - interestedparty333

13

让我给你一个实证的答案:在我们的实时应用程序中,我们经常这样做,而且它完全正常工作。这适用于Wintel和PowerPC的MSVC以及Linux和Mac的GCC,即使是具有构造函数的类。

我不能引用C++标准的详细规定,只有实验证据支持这一点。


在看到确凿的反证材料之前,我会接受这个答案。是的,这不是好的通用实践,可能会导致潜在的未来维护问题,但在这种特定情况下,性能要求至关重要。 - SmacL
2
如果您确定将使用哪个编译器以及将在哪种硬件上运行,那么通用性可能不是那么重要。 - Crashworks
23
我喜欢当人们接受那些告诉他们想听的答案。 - anon
@Neil,这个问题被提出来是想知道为什么memcpy不能工作。其他答案并没有提供这方面的原因,它们只是给出了为什么这是个坏主意的好理由,我完全接受。在这种情况下,性能不是一个模糊的要求,而是至关重要的。 - SmacL
1
同意Neil的观点,但在这种特定情况下,我认为使用memcpy是技术上安全的。话虽如此,这也是一种非常糟糕的编程实践,几乎永远不应该这样做。 - John Dibling
2
即使是具有构造函数的类,使用even for classes that have constructors也会破坏该类的POD类型(除非它们自C++11以来被默认),并产生未定义行为。这个答案怎么能得到这么多赞,更不用说它还被接受了呢? - clickMe

9

你可以这样做。但首先要问自己:

为什么不使用编译器提供的拷贝构造函数来进行成员逐一复制呢?

你是否存在需要优化的特定性能问题?

当前实现包含所有POD类型:如果有人更改它会发生什么?


1
如果您有一个大的结构体数组,使用memcpy可能会更快,许多编译器使用高效的本机指令进行实现(即整个向量的SIMD复制;缓存操作以一次移动整个行等)。 - Crashworks
根据 Lippman 的说法,如果类没有明确的复制构造函数并且类表现出按位复制语义(如 PODs),那么编译器很可能不会合成复制构造函数,而是进行按位复制。这显然得到了 ARM 的支持,但我还没有查阅它。 - John Dibling
为什么不直接使用编译器提供的复制构造函数来进行成员逐一复制呢?举个例子,如果所涉及的对象包含具有非平凡构造函数或运算符=的联合体,这会为成员逐一复制带来无望的歧义。在这种情况下,您需要进行原始字节级别的复制(当然,前提是成员本身使对象符合memcpy)。 - underscore_d
@underscore_d:问题中的对象包含所有POD类型。这就是为什么我把它作为一个问题提出来的原因! - johnsyweb
1
@Johnsyweb 当然,在这种情况下,这可能是最好的选择。我只是做了一个一般性/修辞性的观察,可能适用于其他情况下相同的问题。可能与主题无关,而且表述也不太清楚...我的错! - underscore_d
显示剩余5条评论

9

你的类有构造函数,因此它在C结构体的意义上不是POD类型。因此,使用memcpy()复制它不安全。如果你需要POD数据,请删除构造函数。如果你需要非POD数据,其中受控构建至关重要,请不要使用memcpy()——你不能同时拥有两者。


感谢您一如既往的回复,Neil。您能否指引我一些支持这种做法的参考资料呢?我将我的类分成了一个POD类,如果需要的话再从它派生出来,但我仍然无法理解为什么有必要这样做。 - SmacL
1
@ShaneMacLaughlin 搜索_trivial constructor_。你的构造函数并不是trivial,因为它是用户提供的。只有trivial类型才能使用memcpy安全地进行复制。我不知道为什么那些荒谬和离题的高票答案会出现在那里,当整个线程归结为类是否可以按位复制(trivial)时。参见:https://dev59.com/3G035IYBdhLWcg3wbPU5 对这个被忽视的答案点赞。 - underscore_d

8

还有其他需要注意的问题吗?

是的:你的代码做出了某些假设,这些假设既不被建议也未经记录(除非你特别记录)。这对于维护来说是一场噩梦。

此外,你的实现基本上属于黑客行为(如果必要就不是坏事),可能会依赖于你当前编译器如何实现。

这意味着,如果你从现在开始一年后(或五年后)升级编译器/工具链(或者只是更改当前编译器的优化设置),没有人会记得这个黑客(除非你花费大力气使其可见),你可能最终会遇到未定义行为,几年后开发人员会咒骂“谁干的?”

这并不是决策不明智,而是(或将来)维护人员无法预料到它。

为了最小化这种(意外性?),我建议将类移入一个命名空间中的结构体中,该命名空间基于类的当前名称,并且结构体内没有任何内部函数。那么你就清楚地知道你正在查看一个内存块,并将其视为内存块。

与其写成:

class MyClass
{
public:
    MyClass();
    int a,b,c;
    double x,y,z;
};

#define  PageSize 1000000

MyClass Array1[PageSize],Array2[PageSize];

memcpy(Array1,Array2,PageSize*sizeof(MyClass));

您需要具备以下能力:

namespace MyClass // obviously not a class, 
                  // name should be changed to something meaningfull
{
    struct Data
    {
        int a,b,c;
        double x,y,z;
    };

    static const size_t PageSize = 1000000; // use static const instead of #define


    void Copy(Data* a1, Data* a2, const size_t count)
    {
        memcpy( a1, a2, count * sizeof(Data) );
    }

    // any other operations that you'd have declared within 
    // MyClass should be put here
}

MyClass::Data Array1[MyClass::PageSize],Array2[MyClass::PageSize];
MyClass::Copy( Array1, Array2, MyClass::PageSize );

这样做有以下好处:

  • 清楚表明 MyClass::Data 是一个 POD 结构,而不是一个类(二进制它们将相同或非常接近 - 如果我记得正确的话),但这样程序员阅读代码时也能看到。

  • 集中使用 memcpy(如果你必须改用 std::copy 或其他方法)两年后你只需要在一个地方进行更改。

  • 保持对 memcpy 的使用靠近 POD 结构的实现。


就维护而言,我的现有数组实现已经包括一个标志来启用或禁用浅复制。从维护的角度来看,如果由于任何原因memcpy优化不再可行,那么关闭它只是一个简单的问题。 - SmacL
应该是 memcpy(a1, a2, count * sizeof(Data)); - BЈовић
2
由于构造函数不太常规,你可能最终会遇到未定义的行为。这实际上意味着所述 UB 可能会在以后被检测到。并且仅仅因为 UB 当前没有破坏任何东西,也不意味着它应该被使用。由于这个非常数学问题,他已经承受了 UB 的风险。 - underscore_d

6
您可以使用 memcpy 来复制POD类型的数组。为boost::is_pod添加静态断言,这是一个好主意。您的类现在不是POD类型。
算术类型,枚举类型,指针类型和成员指针类型都是POD。POD类型的cv-qualified版本本身也是POD类型。一个POD数组本身就是POD。如果结构体或联合体的所有非静态数据成员都是POD,则它本身是POD,如果它具有:
  • 没有用户声明的构造函数。
  • 没有私有或受保护的非静态数据成员。
  • 没有基类。
  • 没有虚函数。
  • 没有引用类型的非静态数据成员。
  • 没有用户定义的复制赋值运算符。
  • 没有用户定义的析构函数。

@Neil,我知道这个问题。我已经编辑了我的答案,以消除可能的误读。 - Kirill V. Lyadvinsky
1
如果TR1可用,他可以使用std::tr1::has_trivial_copy等。 - Derek Ledbetter

3
我注意到你承认这里存在问题。你也意识到了潜在的缺点。
我的问题是维护方面的。您是否有信心没有人会在此类中包含一个字段来破坏您的优化?我不确定,我是一名工程师,而不是先知。
所以,为什么不尝试完全避免复制操作呢,而不是试图改进它呢?
更改用于存储的数据结构是否可能停止移动元素...或者至少不那么频繁。
例如,你知道Python模块“blist”吗?B+树可以允许索引访问,性能与向量相当(稍微慢一些),同时最大程度地减少插入/删除时要移动的元素数量。
也许你应该专注于找到更好的集合,而不是采用快捷而肮脏的方法。

1

在非POD类上调用memcpy是未定义的行为。我建议遵循Kirill的断言提示。使用memcpy可能更快,但如果复制操作在您的代码中不是性能关键,则只需使用位拷贝。


4
“要么使用memcpy,要么使用按位拷贝。” - 呃...什么意思?这两个术语有相同的含义。您的意思是“逐成员”拷贝,对吗? - underscore_d

1

当谈到你所提到的情况时,我建议你声明结构体而不是。这样做可以使代码更易读(并且争议较少 :)),默认访问限定符为public。

当然,在这种情况下你可以使用memcpy,但要注意不建议在结构体中添加其他类型的元素(如C++类),因为你不知道memcpy会对它们产生什么影响。


是的,我正在考虑将我的类转换为结构体,然后将其嵌入到另一个类中或者派生自该类。 - SmacL
当然,在这种情况下你可以使用memcpy。胡说八道,不行,你不能这样做。UB不算。用户提供的构造函数的存在意味着该类不是平凡可复制的,因此对其进行memcpy是未定义行为。 - underscore_d

1

正如John Dibling所指出的那样,您不应手动使用memcpy。相反,请使用std::copy。如果您的类是可复制的,std::copy将自动执行memcpy操作。它甚至可能比手动memcpy更快

如果使用std::copy,您的代码将更易读,并且始终使用最快的复制方式。如果稍后更改类的布局以使其不再可复制,使用std::copy的代码将不会中断,而手动调用memcpy的代码则会。

现在,如何确定您的类是否可复制?同样,std::copy可以检测到这一点。它使用:std::is_trivially_copyable。您可以使用static_assert来确保保持此属性。

请注意,std::is_trivially_copyable只能检查类型信息。它不理解语义。以下类是平凡可复制的类型,但位拷贝将是一个错误:
#include <type_traits>

struct A {
  int* p = new int[32];
};

static_assert(std::is_trivially_copyable<A>::value, "");

在位拷贝之后,副本的ptr仍将指向原始内存。还请参阅三法则


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