高效使用 std::move 聚合 std 容器中的所有对象实例

efficient use of std::move to aggregate all object instances in a std container

提问人:sith 提问时间:12/28/2022 最后编辑:sith 更新时间:12/29/2022 访问量:87

问:

我需要将程序中的某些实体创建集中到一个容器中,并且我想将 std::move 的效率与移动构造函数一起使用,以将程序中创建的所有实体聚合到一个容器中,而无需额外的复制或实例分配。不幸的是,使用最流行的 std::vector 容器会带来向量内部管理开销(或者是否依赖于编译器实现??

例如

class Item {
public :
  static int Count;
  int ID;

  Item() : ID(Count++)
  { cout<<"   Item CREATED - ID:"<<ID<<endl; }

  Item(const Item &itm) : ID(Count++)
  { cout<<"   Item COPY CREATED - (ID:"<<ID<<") <= (ID:"<<itm.ID<<")\n"; }

  Item(const Item &&itm) : ID(Count++)
  { cout<<"   Item MOVE CREATED - (ID:"<<ID<<") <= (ID:"<<itm.ID<<")\n"; }

  ~Item() { cout<<" Item DELETED - (ID:"<<ID<<") \n\n"; }
};

int Item::Count = 0;

void VectorOfItemTest() {
  std::vector<Item> ItemVec;

  for(int idx=0; idx<3; idx++) {
    std::cout<<" { loop "<<idx<<std::endl;
    Item itemInst;
    ItemVec.push_back(std::move(itemInst));
    std::cout<<" } "<<idx<<std::endl;
  }
}

产生输出:

-----------------------------
 { loop 0
   Item CREATED - ID:0
   Item MOVE CREATED - (ID:1) <= (ID:0)
 } 0

   Item DELETED - (ID:0)
 { loop 1
   Item CREATED - ID:2
   Item MOVE CREATED - (ID:3) <= (ID:2)
   Item COPY CREATED - (ID:4) <= (ID:1)
   Item DELETED - (ID:1)
 } 1

   Item DELETED - (ID:2)
 { loop 2
   Item CREATED - ID:5
   Item MOVE CREATED - (ID:6) <= (ID:5)
   Item COPY CREATED - (ID:7) <= (ID:4)
   Item COPY CREATED - (ID:8) <= (ID:3)
   Item DELETED - (ID:4)
   Item DELETED - (ID:3)
 } 2

   Item DELETED - (ID:5)
   Item DELETED - (ID:7)
   Item DELETED - (ID:8)
   Item DELETED - (ID:6)

是否可以避免在 for 循环中导致匹配 delete-s 的额外 copy-s?

有没有一个容器(或者我们可以以任何方式使用 std::vector)我们可以获得如下所示的所有循环输出?

 { loop X
   Item CREATED - ID:X
   Item MOVE CREATED - (ID:X+1) <= (ID:X)
 } X
 Item DELETED - (ID:X)

我已经看过为什么需要 std::move 来调用 std::vector 的移动分配运算符 为什么 std::move 会复制 rvalue 或 const 左值函数参数的内容? 还有一些其他的,但仍然不清楚我如何使用 std::move 有效地使用 std::vector(或其他容器)。

我发现了一个被拒绝的问题,很难理解对象生命周期、复制、移动构造函数,我猜接近我在这里指的是什么。

[更新1]使用指针:我现有的代码使用指针,避免了额外的分配和复制。我正试图在我的代码中消除指针的使用——因此这个问题。如果此更改使内存分配和 copy-s 加倍,我将恢复到指针。

[更新 2] @MooingDuck使用 std::d eque 的建议解决了这个问题,而无需 reserve() 或 noexcept 移动构造函数。我实际上正在设计一个 std::vector 包装器数组以避免 std::vector 调整大小,因为我还需要指向实体的指针保持有效以支持遗留代码。std::d eque 似乎正是这样做的

"The storage of a deque is automatically expanded and contracted
as needed. Expansion of a deque is cheaper than the expansion of
a std::vector because it does not involve copying of the existing
elements to a new memory location. On the other hand, deques 
typically have large minimal memory cost; a deque holding just one 
element has to allocate its full internal array (e.g. 8 times the 
object size on 64-bit libstdc++; 16 times the object size or 4096 
bytes, whichever is larger, on 64-bit libc++)."

deque测试

void DequeOfItemTest() {
  std::deque<Item> ItemDQ;
  for(int idx=0; idx<3; idx++) {
    std::cout<<" { deque loop "<<idx<<std::endl;
    Item itemInst;
    ItemDQ.push_back(std::move(itemInst));
    Item &refToItem = ItemDQ[ItemDQ.size()-1];
    Item *ptrToItem = &refToItem;
    std::cout<<" } "<<idx<<std::endl;
  }
}

这现在产生了与我想要的相同的输出 - 堆栈上的单个实体分配,然后移动到容器中并删除堆栈分配

-----------------------------
 { deque loop 0
   Item CREATED - ID:0
   Item MOVE CREATED - (ID:1) <= (ID:0)
 } 0
   Item DELETED - (ID:0)

 { deque loop 1
   Item CREATED - ID:2
   Item MOVE CREATED - (ID:3) <= (ID:2)
 } 1
   Item DELETED - (ID:2)

 { deque loop 2
   Item CREATED - ID:4
   Item MOVE CREATED - (ID:5) <= (ID:4)
 } 2
   Item DELETED - (ID:4)

   Item DELETED - (ID:5)

   Item DELETED - (ID:3)

   Item DELETED - (ID:1)

-----------------------------

[注意] 正如下面的评论和答案(以及 VS2022)中所建议的那样,将移动构造函数和移动赋值运算符声明为 noexcept 是一种很好的做法,因为它允许像 std::vector 这样的容器使用更有效的移动操作而不是复制操作。

C++11 容器 std move-semantics

评论

1赞 Mooing Duck 12/28/2022
std::vector::reserve
1赞 Mooing Duck 12/29/2022
再次阅读您的问题和评论后,您可能会对 感兴趣。它很少使用,但允许您在列表的两端添加或删除项目,而无需复制所有元素。不过,在几乎所有其他情况下,它都比向量慢得多std::deque
1赞 Mooing Duck 12/29/2022
旁注:无论如何,您都应该使移动构造函数/赋值。它让很多事情变得更好noexcept
1赞 Mooing Duck 12/29/2022
还要注意:如果你能避免一开始就使用本地,并用它来构建你的类,那么你也可以消除移动。ItemDQ.emplace_back(...)
1赞 Mooing Duck 12/29/2022
是的,即使您不使用异常,将移动构造函数和赋值标记为 as 也会告诉其他代码移动运算符可以安全使用。在许多情况下,将使用移动构造函数和赋值(如果是),否则将使用复制构造函数和赋值。noexceptstd::vectornoexcept

答:

3赞 Howard Hinnant 12/28/2022 #1

要实现您所说的理想,需要两件事:

首先,您必须将移动构造函数标记为 。如果它随后抛出异常,则被调用,因此它确实必须被设计为永远不会抛出。noexceptstd::terminate

Item(const Item &&itm) noexcept : ID(Count++)
{ cout<<"   Item MOVE CREATED - (ID:"<<ID<<") <= (ID:"<<itm.ID<<")\n"; }

这将使您了解:

{ loop 0
   Item CREATED - ID:0
   Item MOVE CREATED - (ID:1) <= (ID:0)
 } 0

 Item DELETED - (ID:0) 
 { loop 1
   Item CREATED - ID:2
   Item MOVE CREATED - (ID:3) <= (ID:2)
   Item MOVE CREATED - (ID:4) <= (ID:1)
 Item DELETED - (ID:1) 
 } 1

 Item DELETED - (ID:2) 
 { loop 2
   Item CREATED - ID:5
   Item MOVE CREATED - (ID:6) <= (ID:5)
   Item MOVE CREATED - (ID:7) <= (ID:3)
   Item MOVE CREATED - (ID:8) <= (ID:4)
 Item DELETED - (ID:3) 
 Item DELETED - (ID:4) 
 } 2

 Item DELETED - (ID:5) 
 Item DELETED - (ID:6) 
 Item DELETED - (ID:7) 
 Item DELETED - (ID:8) 

上面唯一改变的是,你之前的副本变成了移动。

之所以需要这样做,是为了维护 C++98/03 的强异常保证。这意味着,如果在 期间抛出任何异常,则 的值不会发生任何变化。vector::push_backpush_backvector

其次,您需要在向量中留出足够的空间,这样就不需要分配更大的缓冲区:reservepush_back

std::vector<Item> ItemVec;
ItemVec.reserve(3);

这让你明白到理想:

 { loop 0
   Item CREATED - ID:0
   Item MOVE CREATED - (ID:1) <= (ID:0)
 } 0

 Item DELETED - (ID:0) 
 { loop 1
   Item CREATED - ID:2
   Item MOVE CREATED - (ID:3) <= (ID:2)
 } 1

 Item DELETED - (ID:2) 
 { loop 2
   Item CREATED - ID:4
   Item MOVE CREATED - (ID:5) <= (ID:4)
 } 2

 Item DELETED - (ID:4) 
 Item DELETED - (ID:5) 
 Item DELETED - (ID:3) 
 Item DELETED - (ID:1) 

当调用 时,将分配一个新的缓冲区,并将所有现有元素移动到新缓冲区...除非您的移动构造函数不是 ,在这种情况下,所有现有元素都将复制到新缓冲区。push_backItemVec.size() == ItemVec.capacity()noexcept

评论

0赞 Mooing Duck 12/28/2022
好吧,还有一种可能性,它摆脱了 3 个析构函数和 3 个动作,具体取决于他的真实代码。coliru.stacked-crooked.com/a/98852352c4b35f2bemplace_back
1赞 Howard Hinnant 12/28/2022
emplace_back没有为我更改日志记录输出(完成前两个步骤后)。
0赞 Mooing Duck 12/28/2022
右。我能够通过消除我们移动的本地来消除日志记录,这就是为什么我注意到“取决于他的真实代码”。emplace_back
0赞 sith 12/28/2022
感谢您@HowardHinnant的快速回复。noexcept 确实将额外的 copy-s 变成了额外的 move-s。当向量大小为数千时,我猜由于 std::vector 设计,每次向量调整大小都会导致数千个额外的移动开销。这确实消除了 copy-s,尽管假设 move-s 的开销最小。