Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Left shift in StaticVector::erase(), add tests #7879

Merged
merged 31 commits into from
Apr 3, 2025

Conversation

Yggdrasill
Copy link
Contributor

@Yggdrasill Yggdrasill commented Mar 24, 2025

Hello,

While I was working on using StaticVector for vendor item arrays, I noticed that StaticVector::erase() doesn't seem to behave as expected. I asked about this on Discord and @glebm concluded it is a bug, and says that it is intended to work exactly like std::vector's erase.

The following changes have been made to static_vector.hpp:

  • std::move is called before std::destroy_at, shifting items left.
  • std::destroy_at() now destroys the entries at the back of the array after the move.
  • StaticVector::clear() returns early if there are no member items.

I am not an expert in C++ and am not 100% confident in this PR or the changes made, so feedback is greatly appreciated.

Regards,
Yggdrasill

	Currently the behaviour of StaticVector::erase() is broken.
	Whereas it should work just like std::vector's erase(), it just
	destroys items and leaves them in place.

	This commit changes the behaviour so that it first calls
	std::move and moves all items back, then proceeds to call
	std::destroy_at() on the elements at the back of the array.
@Yggdrasill Yggdrasill changed the title Shuffle items back in StaticVector::erase() Shuffle items left in StaticVector::erase() Mar 24, 2025
@chasedjones88
Copy link
Contributor

Consider using the term "Shift" instead of "Shuffle" as shuffle indicates, at least to me, an element of random reordering.

@Yggdrasill Yggdrasill changed the title Shuffle items left in StaticVector::erase() Shift items left in StaticVector::erase() Mar 24, 2025
	This implements the following tests:

	- StaticVector_push_full() is a static test that fills the array
	  and checks its expected size and elements.
	- StaticVector_erase() is a static test that tests for several
	  edge cases. These edge cases are:
		- Erasing from the beginning of an array.
		- Erasing from the end of an array
		- Erasing out of bounds ahead of the array's head.
		- Erasing out of bounds behind the array's tail.
	- StaticVector_erase_range() is a mixed static and random test
	  that tests that ranges are appropriately erased, including
	  ranges of 0.

Static test erasures of zero size

Style fixups
@Yggdrasill Yggdrasill changed the title Shift items left in StaticVector::erase() Shift items left in StaticVector::erase(), implement tests Mar 25, 2025
@Yggdrasill
Copy link
Contributor Author

Yggdrasill commented Mar 25, 2025

Sorry for the messy commit history. I always forget to run clang-format before pushing, and MSVC produced some test failures that I can't produce locally on Debian. I set up a VM to run these tests locally now.

There is at this point a possible change that I'd like to discuss here. Currently StaticVector::erase() returns early if erasures are requested out of bounds, and leaves the array untouched. What I would like to ask is if it's desired that erase() should clamp these requests to the bounds of the array, and operate normally. When called with either:

container.erase(container.begin() - 1);
container.erase(container.end() - 1, container.end() + 1);

Would it be better to return early as it does presently, or should it fix up these values to within bounds? Or should it perhaps do an assertion for these cases?

@Yggdrasill Yggdrasill changed the title Shift items left in StaticVector::erase(), implement tests Left shift in StaticVector::erase(), implement tests Mar 25, 2025
@Yggdrasill Yggdrasill changed the title Left shift in StaticVector::erase(), implement tests Left shift in StaticVector::erase(), add tests Mar 25, 2025
@AJenbo
Copy link
Member

AJenbo commented Mar 25, 2025

I think we both made the mistake thinking this would be a quick little change :D

@ephphatha
Copy link
Contributor

Would it be better to return early as it does presently, or should it fix up these values to within bounds? Or should it perhaps do an assertion for these cases?

Adding asserts will be fine, I don't know exactly where in the spec it's defined but usually the expectation is the caller will do the right thing and not try erase invalid values.

For example, you can assert(element >= begin() && element < end()) inside erase(T *element) to match the expected behaviour of std::vector.

container.erase(container.begin() - 1);

would then trigger an assert failure (or crash in release builds).

and for erase(T *first, T* last) assert(first >= begin() && last <= end() && first <= last) is sufficient.

container.erase(container.end() - 1, container.end() + 1);

would fail the assert

container.erase(container.begin(), container.begin()) would be fine and result in a no-op, even if the container is empty.

@Yggdrasill
Copy link
Contributor Author

Yggdrasill commented Mar 25, 2025

Adding asserts will be fine, I don't know exactly where in the spec it's defined but usually the expectation is the caller will do the right thing and not try erase invalid values.

I've added the asserts and also added test cases for them, which are disabled in release builds. In debug builds the tests will also test assertions.

Copy link
Contributor

@ephphatha ephphatha left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good, one more nit and pointing out something you did that I didn't think of but makes sense for the other test case too :)

ephphatha
ephphatha previously approved these changes Mar 25, 2025
Copy link
Contributor

@ephphatha ephphatha left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice work, sorry for the back and forth for what is a fairly isolated change 😅

looks correct to me but it’s getting late in the evening so I’ll hope a second set of eyes can do the merge (or I’ll check again tomorrow)

@Yggdrasill
Copy link
Contributor Author

I appreciate the review, don't worry about the back and forth. Thank you.

	This commit correctly implements StaticVector::erase() and
	cleans up the code that was here before. Specifically, this code
	attempts to closely mirror libstdc++'s implementation of
	std::vector::erase():

		- move elements left and overwrite erased elements
		- clean up tail elements with std::destroy()
		- shrink the size of the vector
@Yggdrasill
Copy link
Contributor Author

Yggdrasill commented Apr 2, 2025

So I've researched this more, discussed this some more on Discord, and looked at a libstdc++ implementation of std::vector:

    _M_erase(iterator __position)
    {
      if (__position + 1 != end())
	_GLIBCXX_MOVE3(__position + 1, end(), __position);
      --this->_M_impl._M_finish;
      _Alloc_traits::destroy(this->_M_impl, this->_M_impl._M_finish);
      _GLIBCXX_ASAN_ANNOTATE_SHRINK(1);
      return __position;
    }

This code moves [__position + 1, end()) to __position, then shrinks the size of the vector, destroys the tail element and tells the address sanitiser that the size shrunk by one. I have closely mirrored this in the new commit, and it seems like the correct implementation.

In the new commit, the elements being moved to are overwritten, both with move assignable and copy assignable types. The elements trailing at the end are now garbage, and need to be cleaned up, where std::destroy comes in. The elements that shouldn't be erased have already been moved, and std::move takes care of cleaning up loose ends like that, so it really shouldn't be a problem. Conceptually, it is somewhat similar to this C code, as far as move assignable types are concerned:

int *ptr1 = malloc(sizeof(*ptr1));
int *ptr2 = malloc(sizeof(*ptr2));
free(ptr1);
ptr1 = ptr2;
ptr2 = NULL;

The build is failing right now, but only because of a CMake update in the build environment.

@AJenbo AJenbo merged commit dbf7c77 into diasurgical:master Apr 3, 2025
14 of 23 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

7 participants