Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Cost of std::vector::push_back either succeeding or having no effect?

If I understand correctly, std::vector::insert does not guarantee a commit-or-rollback for std::vectors (it does for std::lists for obvious reasons) in the case an exception is thrown during copying or moving, because of the high cost of checking for exceptions. I recently saw that push_back DOES guarantee either successful insertion at the end or nothing happens.

My question is the following. Suppose that during a push_pack the vector has to be resized (reallocation happens). In that case, all elements have to be copied in the new container, via copy or move semantics. Assuming that the move constructor is not guaranteed noexcept, std::vector will then use copy semantics. So, to guarantee the above behaviour of push_pack, std::vector has to check for successful copying, and if not, roll back via a swap the initial vector. Is this what is happening, and if so, isn't this expensive? Or, because reallocation happens rarely, one can say that the amortized cost is low?

like image 825
vsoftco Avatar asked May 25 '14 19:05

vsoftco


People also ask

Is vector Push_back expensive?

That's a depends. Normally push_back is pretty cheap, but every now and then it has to resize to fit more elements, and that can be costly. In this case you can gain a lot by passing references or taking advantage of move semantics so that you aren't copying Object s around when you could be moving them.

What is the time complexity of Push_back in vector?

push_back(): Inserts a new element at the end of the vector. Its time complexity is O(1).

What is the time complexity of Push_back in C++?

push_back is amortized O(1) time complexity. There is normally extra space and no extra work, the item is inserted in one iteration. When extra space is required, there may be an O(N) copy loop, not O(N^2).


Video Answer


2 Answers

In C++98/03 we (obviously) had no move semantics, only copy semantics. And in C++98/03, push_back has the strong guarantee. One of the strong motivations in C++11 was to not break existing code that relied on this strong guarantee.

The C++11 rules are:

  1. If is_nothrow_move_constructible<T>::value is true, move, else
  2. If is_copy_constructible<T>::value is true, copy, else
  3. If is_move_constructible<T>::value is true, move, else
  4. The code is ill-formed.

If we are in the case of 1 or 2, we have the strong guarantee. If we are in case 3, we have only the basic guarantee. Since in C++98/03, we were always in case 2, we have backwards compatibility.

Case 2 is not expensive to maintain the strong guarantee. One allocates the new buffer, preferably using an RAII device such as a second vector. Copies into it, and only if all of that succeeds, swap *this with the RAII device. This is the cheapest way to do things, whether or not you want the strong guarantee, and you get it for free.

Case 1 is also inexpensive to maintain the strong guarantee. The best way I know of is to first copy/move the new element into the middle of the new allocation. If that succeeds, then move the elements from the old buffer and swap.

More Detail Than You Probably Want

libc++ accomplishes all 3 cases with the same algorithm. To accomplish this, two tools are used:

  1. std::move_if_noexcept
  2. A non-std vector-like container where the data is contiguous, but can start at a non-zero offset from the beginning of the allocated buffer. libc++ calls this thing split_buffer.

Assuming the reallocation case (the non-reallocation case is trivial), the split_buffer is constructed with a reference to this vector's allocator, and with twice the capacity of this vector, and with its starting position set to this->size() (though the split_buffer is still empty()).

Then the new element is copied or moved (depending on which push_back overload we are talking about) to the split_buffer. If this fails, the split_buffer destructor undoes the allocation. If it succeeds, then the split_buffer now has size() == 1, and the split_buffer has room for exactly this->size() elements prior to its first element.

Next the elements are moved/copied in reverse order from this to the split_buffer. move_if_noexcept is used for this, which has a return type of either T const& or T&& exactly as we need as specified by the 3 cases above. On each successful move/copy, the split_buffer is doing a push_front. If successful, the split_buffer now has size() == this->size()+1, and its first element is at a zero offset from the beginning of its allocated buffer. If any move/copy fails, split_buffer's destructor destructs whatever is in the split_buffer and deallocates the buffer.

Next the split_buffer and this swap their data buffers. This is a noexcept operation.

Finally the split_buffer destructs, destructing all of its elements, and deallocating its data buffer.

No try-catches needed. There is no extra expense. And everything works as specified by C++11 (and summarized above).

like image 177
Howard Hinnant Avatar answered Oct 29 '22 02:10

Howard Hinnant


That is what is happening and it can be expensive. It was decided that the strong guarantee of push_back is more important than the performance from move semantics.

like image 41
nwp Avatar answered Oct 29 '22 02:10

nwp