在没有动态分配的情况下在构造时组合对象时避免数据的多个副本

Posted

技术标签:

【中文标题】在没有动态分配的情况下在构造时组合对象时避免数据的多个副本【英文标题】:Avoid multiple copy of data when composing objects at construction without dynamic allocation 【发布时间】:2021-05-28 14:14:29 【问题描述】:

我们有由类表示的分层消息。它们用于通过序列化/反序列化在线程和组件之间发送消息。在我们的用例中,我们使用std::variant<InnerA, InnherB, ...>,但为了简化,我们的代码类似于:

class Inner 
  public:
    Inner(uint8_t* array, uint16_t arrayLength) 
        m_payloadLength = arrayLength; // Let's assume arrayLength is always < 256
        memcpy(m_payload.data(), array, arrayLength));
    
    std::array<uint8_t, 256> m_payload;
    uint16_t m_payloadLength;


class Outer 
  public:
    Outer(const Inner& inner): m_inner(inner);
    Inner m_inner;


class OuterOuter 
  public:
    OuterOuter(const Outer& outer): m_outer(outer);
    Outer m_outer;


因此我们需要创建一个OuterOuter对象

int main(int argc, char** argv)
   uint8_t buffer[4]  = 1,2,3,4;
   Inner inner(buffer, 4);
   Outer outer(inner);
   OuterOuter outerOuter(outer);
   addToThreadQueue(outerOuter);


现在的问题是,我们使用的是嵌入式设备,因此我们不能将动态内存与 malloc 和 new 一起使用。截至目前,有效载荷内容将被复制三次吗?一次是为了创建inner,一次是在Outer中调用Inner的拷贝构造函数,一次是在OuterOuter中调用Outer的拷贝构造函数?如果是这样,有没有办法在不使用动态内存的情况下避免所有这些复制?如果有办法将我的意图传递给编译器,那么可能会进行优化,如果它还没有优化的话。

理想情况下,我们会避免 OuterOuter 类采用所有子类构造参数,因为我们的树非常深并且我们使用 std::variant。在此示例中,它将是 OuterOuter(uint8_t* array, uint16_t arrayLength)Outer(uint8_t* array, uint16_t arrayLength),然后 Outer 将构建 Inner

【问题讨论】:

是的,有效载荷被复制三次。为避免这种情况(同时也避免newmalloc),只需使用指向有效负载内容的指针,而不是将有效负载复制到数组成员变量中。 问题是,当我们将对象附加到队列以发送到线程时,它会被复制,因为我们不知道它何时会被处理。处理时指针可能无效。 考虑改为默认构造OuterOuter,然后将数据直接写入该实例的Inner 我对你的代码有点困惑。在这段代码中你为什么不使用移动语义?。 @N0ll_Boy • 没有可移动的资源,因此移动语义与副本没有任何不同。 【参考方案1】:

一般来说,现代编译器在优化类层次结构方面做得很好,除了填充连续的内存布局之外,它们的构造没有副作用。

例如,gcc 将您的示例编译为基本上单个类:

main:
  sub rsp, 280
  mov eax, 4
  mov rdi, rsp
  mov WORD PTR [rsp+256], ax
  mov DWORD PTR [rsp], 67305985
  call addToThreadQueue(OuterOuter const&)
  xor eax, eax
  add rsp, 280
  ret

see on godbolt

除此之外,编译器还可以在某些情况下跳过一些副作用。例如,在下面的示例中,gcc 通过一个称为“heap elision”的过程完全摆脱了堆分配。

#include <memory>

extern int foo(int);
extern void bar(int);

struct MyStruct 
    int data;

    MyStruct() 
        auto val = std::make_unique<int>(12); 
        data = foo(*val);
    
;

int main(int argc, char** argv)
   MyStruct x;
   bar(x.data);

变成:

main:
  sub rsp, 8
  mov edi, 12
  call foo(int)
  mov edi, eax
  call bar(int)
  xor eax, eax
  add rsp, 8
  ret

see on godbolt

显然,您需要仔细检查您自己的代码库,但通常的说法仍然存在:“首先编写易于阅读和维护的代码,并且只有当编译器处理不好时,您才应该费心跳过箍优化它。”

【讨论】:

Here is 当编译器无法查看 Inner 的 ctor(或者当它具有无法优化的副作用时)时 asm 的样子。 具体来说,编译器别无选择,只能将另一个 TU 中定义的函数视为具有所有可能的副作用。 是的,结果你会得到一堆内存副本。然而,语言有一个解决方案,允许您就地构造对象(就在需要构造它们的地方)。【参考方案2】:

您可以使用inplacer(请参阅this post)。你的代码看起来像like this:

#include <type_traits>
#include <array>
#include <cstdint>
#include <cstring>


using namespace std;


template<class F>
struct inplacer

    F f_;
    operator std::invoke_result_t<F&>()  return f_(); 
;

template<class F> inplacer(F) -> inplacer<F>;


struct Inner

    Inner(uint8_t* data, size_t len)
        : len_(len) // Let's assume arrayLength is always < 256
    
        memcpy(payload_.data(), data, len*sizeof(*data));
    

    std::array<uint8_t, 256>    payload_;
    size_t                      len_;
;

struct Outer

    template<class T>
    Outer(T&& inner): m_inner(std::forward<T>(inner)) 

    Inner m_inner;
;

struct OuterOuter

    template<class T>
    OuterOuter(T&& outer): m_outer(std::forward<T>(outer)) 

    Outer m_outer;
;


void addToThreadQueue(OuterOuter const&);

int main()

    uint8_t buffer[4]  = 1,2,3,4;
    OuterOuter outerOuter inplacer[&] return Innerbuffer, size(buffer);  ;
    addToThreadQueue(outerOuter);
    return 0;

这种方法将使您减少对编译器优化的依赖。如果您的 ctor 有副作用(或者编译器无法在此翻译单元中分析),它也可以工作。

main:
        sub     rsp, 280
        mov     rdi, rsp
        mov     DWORD PTR [rsp], 67305985
        mov     QWORD PTR [rsp+256], 4
        call    addToThreadQueue(OuterOuter const&)
        xor     eax, eax
        add     rsp, 280
        ret

编辑:here is 类似的解决方案(但没有inplacer)——它不适用于聚合,但我敢打赌在你的情况下这不是问题。

【讨论】:

虽然这是一个很好且干净的解决方案,我想使用它,但它在我的上下文中不可用,因为我不能使用堆分配,因为我们的代码在嵌入式控制器上运行。 lambda [&amp;] return Innerbuffer, size(buffer); 捕获当前上下文,根据编译器和上下文的大小,该上下文可能分配也可能不分配堆。我们不使用带有上下文的 lambda,因为我们只想确保永远不会使用堆。 @XavierGroleau 是的,std::function 使用堆(除非上下文足够小)。 Lambda 从不这样做 @Frank 谢谢!我改进了它a bit further :) @Frank 还有另一种方法可以“修复”这个without SFINAE,但这让 MSVC 不高兴...... @XavierGroleau 我为我的答案添加了另一个解决方案(请参阅底部的编辑)——您可能会发现它更适合/更简洁适合您的情况。

以上是关于在没有动态分配的情况下在构造时组合对象时避免数据的多个副本的主要内容,如果未能解决你的问题,请参考以下文章

有没有办法在不使用(MFC)动态对象创建的情况下在 CSplitterWnd 中创建视图?

有没有办法在不使用 __init__ 的情况下在实例初始化时自动运行方法?

避免使用 Eigen 分解稀疏矩阵时的动态内存分配

C++对象模型探索:什么情况下必须需要默认构造函数

将placement new 与存储类一起使用时的额外构造

在没有 new 的情况下在 C++ 中调用构造函数