-
-
Notifications
You must be signed in to change notification settings - Fork 21.9k
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
Add FixedVector
template to core - a collection that can be used completely on the stack.
#104055
base: master
Are you sure you want to change the base?
Conversation
FixedVector
template.FixedVector
template to core - a collection that can be used completely on the stack.
Love it! Very useful in many cases (like to store the neighbors of a cell for example).
|
fc10d68
to
1df6129
Compare
I've seen |
Boost static_vector for example |
Could you explain what's the rationale for using placement new instead of a relying on a |
That's a good example. I'd like to hear what others think of this alternate name; I don't have a strong preference.
Of course! The reason is performance. Basically, using For non-trivial types though, like |
No preference on the name—both convey the same general idea |
imo something other than static ('Fixed' or any better word we can come up with) is probably better in the name (as I did in 3.x for It can either be global/static, or more commonly on the stack. One alternative I suggested in RC was This disambiguation is an issue because although we have the basic classification of static (unchanging) & dynamic (changing), in c++ the word (and keyword) |
Just to throw an idea, I know people love STL, but I go by the philosophy of if a routine in a template (or a header in general) can fail, and it returns e.g.
versus
I guess the second branch in the Incidentally, Grok tells me part of the reason STL This of course is an issue with all our containers as we tend to favour "safe" versions.
In C++, the pop_back() function for containers like std::vector, std::deque, or std::list doesn't return a success or failure indicator because its design assumes a specific contract: it only operates on a non-empty container, and if that contract is met, it will always succeed. This is a deliberate choice rooted in C++'s philosophy of performance, simplicity, and exception handling.
Here’s why:Precondition-Based Design:pop_back() is defined with the precondition that the container must not be empty. If you call it on an empty container, it results in undefined behavior (UB) rather than a guaranteed failure state. This avoids the overhead of runtime checks (like returning a success/failure flag) in the common case where the programmer ensures the container isn’t empty. C++ prioritizes "don’t pay for what you don’t use"—if you want safety, you’re expected to check empty() or size() beforehand. Performance Optimization:Adding a return value (e.g., a bool for success/failure) would introduce additional complexity and potentially cost CPU cycles for something that’s typically unnecessary in well-written code. Since pop_back() is a simple operation (removing the last element and adjusting the size), the standard library avoids burdening it with extra bookkeeping. Error Handling via Exceptions or Assertions:C++ prefers mechanisms like exceptions or debug-mode assertions over return codes for error handling. For example, some debug implementations of the standard library (e.g., libstdc++ or MSVC) will trigger an assertion failure if you call pop_back() on an empty container, helping catch bugs during development. In release mode, it’s up to the programmer to avoid UB, aligning with C++’s trust in the developer. Historical Context and Consistency:The STL (Standard Template Library), which influenced C++’s standard containers, was designed by Alexander Stepanov with a focus on generic programming and minimalism. Functions like pop_back() were kept lean and consistent with other operations (e.g., push_back() doesn’t return success either—it either works or throws an exception on allocation failure). Adding a success/failure return would deviate from this established pattern. Alternative: You Can Check Yourself:If you need to verify whether pop_back() is safe, you can explicitly check:
This puts the responsibility on the programmer, which is typical in C++’s design. Contrast this with languages like Java or Python, where methods often return status indicators or throw exceptions for invalid operations. In C++, the choice to omit a success/failure return from pop_back() reflects its low-level, performance-driven ethos. If you really want a "safe" version, you could wrap it in a custom function:
But the standard library doesn’t include this because it assumes you’ll manage the preconditions yourself. Thoughts on this design choice? Does it align with how you approach C++ coding? |
This would definitely be a valid way to handle this! As for |
That's also something I've considered in various forms in the past, although returning a |
constexpr void pop_back() { | ||
ERR_FAIL_COND(_size == 0); | ||
_size--; | ||
ptr()[_size].~T(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
no if constexpr (!std::is_trivially_destructible_v<T>)
here ?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think it just gets compiled away for trivial types:
https://godbolt.org/z/71oWoY19P
I think technically we should expect the compiler to optimize away the loop cases as well, but the reason I'm checking there is to make it easier for the compiler / not to risk it leaving in the loop for whatever reason.
static_assert(!force_trivial || std::is_trivially_destructible_v<T>, "force_trivial may only be true if the type is trivially destructible."); | ||
|
||
uint32_t _size = 0; | ||
alignas(T) uint8_t _data[CAPACITY * sizeof(T)]; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
if you wrap this inside a struct and declare one like :
struct FixedVectorInner
{
uint32_t _size = 0;
alignas(T) uint8_t _data[CAPACITY * sizeof(T)];
};
FixedVectorInner inner;
Then you don't need the DATA_PADDING information as you can just memcpy with adress of inner and sizeof(inner). Just a remark.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hrm, that's true. I do think the DATA_PADDING
thing is a bit weird.
Actually, thinking about your suggestion, it should just be possible to use memcpy
with sizeof(other)
! That should do the exact same thing.
Edit: Oh, actually, the reason both aren't possible is because we only want to copy the elements that are actually constructed right now, not the whole buffer.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Well you still can with memcpySize = &(inner.data + size * sizeof(T)) - &inner
but that's not really better than the current one, agreed.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I suspect another option might be to put the _size
inside a union and calculate the size of the union from the T
alignment. Something like that. Maybe there would be some spanner in the works which prevents it...
union
{
uint32_t size = 0;
uint8_t spacer[MIN_SIZE_SIZE];
};
Realistically in most cases it's more likely to just be have the size section use 8 bytes as I guess 8 byte aligned may be slightly faster on 64 bit CPU.
I could possibly do that on FixedArray
but I can't remember what I was storing in it, and I wasn't going for that level of micro-optimization. (It may even be possible to backport this and replace my use there just so we don't have duplicate containers.)
I suppose you could want e.g. T
64 byte aligned to line up with cache lines, something like that, and SIMD sometimes has more alignment requirements. I suppose it depends how much more complex it makes the code to read versus something more basic at this stage.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That's also an interesting idea! C++ sure can do the same thing in so many different ways 😄
I think for now I still like the data padding constant best, because it's only awkward in its construction but looks pretty normal elsewhere.
This is a high performance `Vector`-like object that can be used if the maximum number of objects is small and known, and the objects are needed only temporarily.
This is a high performance
Vector
-like object. In practice, we can use it to reduce the number of per-frame allocations throughout Godot.In particular,
FixedVector
is useful if the maximum number of objects is small and known, and the objects are needed only temporarily. It could also be used to return a small, uncertain number of objects from a function in a performant fashion if the maximum is known.The class can be expanded with more useful functions in the future as needed.
Comparisons
FixedVector
is designed to be as easily exchangeable forVector
andLocalVector
as possible. This can be most easily achieved throughSpan
interfaces.FixedVector
is similar to 3.x'FixedArray
, but some decisions about its design are different.FixedVector
is similar tostd::array
, but it supports adding and removing objects, and a number of other goodies (like conversion toSpan
).FixedVector
is somewhat similar toStringBuffer
, but focuses on being a general purpose collection and does not support arbitrary expansion into the heap.