godotengine / godot-proposals

Godot Improvement Proposals (GIPs)
MIT License
1.08k stars 69 forks source link

Make Packed*Arrays API more consistent with Arrays #9856

Open Vlad-Zumer opened 1 month ago

Vlad-Zumer commented 1 month ago

Describe the project you are working on

ID3 metadata tag editor.

Describe the problem or limitation you are having in your project

Due to godot@#91048 the code samples below does not work.

extends Node

func _ready(): var callableOK: Callable = TestOK.bind(["one", "two", "three"]) callableOK.call()

var callableProblem: Callable = TestProblem.bind(["one", "two", "three"])
callableProblem.call()

func TestProblem(arr: Array[String]) -> void: print("TestProblem arr: %s" % arr)

func TestOK(arr: PackedStringArray) -> void: print("TestOK arr: %s" % arr)


 - Case 2: Calling Between Nodes

```gdscript
# Caller Node

extends Node

func _ready():
    $CalleeNode.OkMethod(["1","2"])
    $CalleeNode.ProblemMethod(["1","2"])
# Callee Node

extends Node

## Does not work
func ProblemMethod(arr: Array[String]):
    print("Received typed array: %s" % arr)

## Works
func OkMethod(arr: PackedStringArray):
    print("Received packed array: %s" % arr)

In trying to solve the first issue (and for performance) I changed from Array[String] to PackedStringArray, but this container is missing some methods as explained in godot@#92562. The code below shows the limitations I encountered while trying to use the PackedStringArray.

extends Node

func _ready() -> void:
    Ok()
    Problem()

func Ok() -> void:
    var arr : Array[String] = ["1","2","3","4","5","10","20","30","40","50","100","200","300","400","500","1000","2000","3000","4000","5000"]

    arr.all(func (item: String) -> bool: return item.length() >= 2)
    arr.any(func (item: String) -> bool: return item.length() == 0)
    arr.back()
    arr.erase("1")
    arr.filter(func (item:String) -> bool: return item.length() == 3)
    arr.front()
    arr.map(func (item:String) -> int: return int(item))
    arr.pick_random()
    arr.pop_at(2)
    arr.pop_back()
    arr.pop_front()
    arr.push_front("0")
    arr.reduce(func (acc: String, item: String) -> String: return acc + item, "")
    arr.shuffle()
    arr.sort_custom(func (item1: String, item2: String) -> bool : return item1.length() < item2.length())

func Problem() -> void:
    var arr : PackedStringArray = ["1","2","3","4","5","10","20","30","40","50","100","200","300","400","500","1000","2000","3000","4000","5000"]

    arr.all(func (item: String) -> bool: return item.length() >= 2)
    arr.any(func (item: String) -> bool: return item.length() == 0)
    arr.back()
    arr.erase("1")
    arr.filter(func (item:String) -> bool: return item.length() == 3)
    arr.front()
    arr.map(func (item:String) -> int: return int(item))
    arr.pick_random()
    arr.pop_at(2)
    arr.pop_back()
    arr.pop_front()
    arr.push_front("0")
    arr.reduce(func (acc: String, item: String) -> String: return acc + item, "")
    arr.shuffle()
    arr.sort_custom(func (item1: String, item2: String) -> bool : return item1.length() < item2.length())

⚪ Related Proposals

Describe the feature / enhancement and how it helps to overcome the problem or limitation

Implementing the missing methods.

Describe how your proposal will work, with code, pseudo-code, mock-ups, and/or diagrams

🟤 GDScript example

extends Node

func _ready() -> void:
    var arr : PackedStringArray = ["1","2","3","4","5","10","20","30","40","50","100","200","300","400","500","1000","2000","3000","4000","5000"]

    arr.erase("1")
    arr.push_front("0")

    arr.shuffle()
    arr.sort_custom(func (item1: String, item2: String) -> bool : return item1.length() < item2.length())
    arr.all(func (item: String) -> bool: return item.length() >= 2)
    arr.any(func (item: String) -> bool: return item.length() == 0)

    arr.filter(func (item:String) -> bool: return item.length() == 3)

    arr.map(func (item:String) -> int: return int(item))
    arr.reduce(func (acc: String, item: String) -> String: return acc + item, "")

    arr.back()
    arr.front()
    arr.pick_random()
    arr.pop_at(2)
    arr.pop_back()
    arr.pop_front()

🟤 C++ method types for PackedArray<T>

// Implementation difficulty : 1
void erase ( T value );
void push_front ( T value ); // low performance, but is it a problem?

// Implementation difficulty : 2
void shuffle ( );
void sort_custom ( Callable func );
bool all ( Callable method ); const
bool any ( Callable method ); const
int bsearch_custom ( T value, Callable func, bool before=true ); const

// Implementation difficulty : 3
PackedArray<T> filter ( Callable method ); const

// Implementation difficulty : 4
Array<Variant> map ( Callable method ); const
Variant reduce ( Callable method, Variant accum=null ); const

// Implementation difficulty : 5
Variant back ( ); const
Variant front ( ); const
Variant pick_random ( ); const
Variant pop_at ( int position );
Variant pop_back ( );
Variant pop_front ( );

If this enhancement will not be used often, can it be worked around with a few lines of script?

All the missing methods are implementable as free functions by the user, but is not rare for them to be used.

Is there a reason why this should be core and not an add-on in the asset library?

This should be part of the core as these are core types used in many places inside godot.

AThousandShips commented 1 month ago

These should be separate proposals as they are very different topics

AThousandShips commented 1 month ago

For the second part of the proposal, it's not really accurate to say the Packed*Array classes are missing any methods, they're not and never were designed to be:

Their purpose is primarily to serve as convenient data transfer units between the engine internals and scripting, to avoid costly conversions every time the engine core needs to do something with data passed from scripting, or provide the same data to scripting. On the c++ side Array is of limited usefulness when it comes to handle data in most cases, but that's the interop type with scripting. That's why the packed array types exist, they exist to provide a useful interface so that we can get the best of both worlds:

The Packed*Array types are designed not primarily for heavy use in scripting, but instead to serve as a cost effective way to transfer data in a way that works for both c++ and scripting

Tl;dr; If you want general purpose functionality, use Array, if you want an interop type with low cost of transfer to the engine internals, use Packed*Array

Vlad-Zumer commented 1 month ago

These should be separate proposals as they are very different topics

If this refers to "⚪ Issue 1: Typed arrays and type coercion" that was only there to who how I ended up hitting the PackedStringArray limitation. That problem already has an issue assigned to it marked as a bug.

For the second part of the proposal, it's not really accurate to say the Packed*Array classes are missing any methods, they're not and never were designed to be (...)

I understand the design behind the Packed Arrays, but I don't understand if this section is supposed to be pushing back on this proposal.

PS: Does the formatting of the proposal text make it seem like there are 2 issues and 2 proposals?

AThousandShips commented 1 month ago

There are two very different topics here, with separate implementation processes, type coercion is separate from adding functions, so should be a separate proposal to discuss and implement, the two implementations would almost certainly be two different PRs due to it being completely different parts of the engine

One is the way the methods are bound, they would likely be bound in core/variant/variant_call.cpp, the coercion would be part of the GDScript module instead, and is a bug, not a proposal

AThousandShips commented 1 month ago

There's also an additional issue with this proposal that I forgot about when first responding: C#

Adding these methods would further increase the lack of parity with C# as Packed*Array doesn't exist in C#, see here, this is already a source of confusion there and this would make that (and writing tutorials etc.) even worse

It would also (depending on how it's implemented) make working with Modules and GDExtension more difficult, as Packed*Array is different in Modules and GDExtension due to the former just being a Vector<T> and the latter being a dedicated interop type, and we aim to make it possible to write code that works both in a module and as GDExtension with minimal modification or #ifdef etc.

Vector<T> for example already has sort_custom but it doesn't take a Callable but a custom sort class, this can't be exposed to scripting, so it would cause issues with handling that parity for example

Further most of these methods (with the exception of the pop methods, back, front, and pick_random methos) couldn't be implemented on Vector<T> itself, because you couldn't have it take a Callable because it isn't guaranteed to be able to pass the argument. So it'd have to be implemented externally. So it would be messy and complicated. This is why Vector doesn't have any Callable based methods in it

jhnsnc commented 1 month ago

@AThousandShips thank you for helping to explain the underlying rationale so clearly!

I am primarily a web developer and recently I've been learning Godot for hobby gamedev purposes (from Unity previously). I think the Web platform's TypedArray presents an interesting comparison here. There are some important limitations with types like Uint8Array, Float32Array, etc in order to preserve their performance benefits (e.g. no "push" or "pop" methods in order to avoid unnecessarily reallocating the underlying buffer). These typed arrays are even more important now that tech like WebAssembly is supported, making it easier and more efficient to share data across contexts.

So with that in mind:

# this loop
for idx in range(file_names.size()-1, 0, -1):
    if file_names[idx].ends_with(".import"):
        file_names.remove_at(idx)

# could be simpler and more efficient as a one-liner
file_names.filter(func(s): return !s.ends_with(".import"))

# this loop is arguably better, but more verbose
var filtered_file_names = PackedStringArray()
var accepted = 0
filtered_file_names.resize(file_names.size())
for idx in file_names.size():
    if !file_names[idx].ends_with(".import"):
        filtered_file_names[accepted] = file_names[idx]
        accepted += 1
filtered_file_names.resize(accepted)

I recognize that the inclusion/exclusion of certain methods from packed arrays is a matter of opinion and priorities, and I'm not claiming to represent anything other than my personal opinions here.

I'm still quite new to Godot and my C++ is rusty, but I'm happy to help contribute as best I can. I appreciate the discussion!

Vlad-Zumer commented 1 month ago

There are two very different topics here, with separate implementation processes, type coercion is separate from adding functions, so should be a separate proposal to discuss and implement, the two implementations would almost certainly be two different PRs due to it being completely different parts of the engine

Ok, so I was misunderstood here. This proposal is only for adding missing methods on the Packed Arrays. The type coercion bit was just meant to explain how I ended up needing these methods on the PackedStringArray. I will reformat the proposal to alleviate this. Thank you!

One is the way the methods are bound, they would likely be bound in core/variant/variant_call.cpp, the coercion would be part of the GDScript module instead, and is a bug, not a proposal

This has already been accepted as a bug in godot@#91048, and godot@#92215 or so it seems, so I call it bug.

Also there seems to already be a more in-depth proposal for "fixing" typed arrays in GDScript, see #7364.

Vlad-Zumer commented 1 month ago
Original message > Adding these methods would further increase the lack of parity with C# as Packed*Array doesn't exist in C#, see [here](https://docs.godotengine.org/en/stable/tutorials/scripting/c_sharp/c_sharp_collections.html#doc-c-sharp-collections-packedarray), this is already a source of confusion there and this would make that (and writing tutorials etc.) even worse

It's true that they don't exist but, with the exception of modifying the array, you can get the same functionality between Packed*Array in GDScript and System.Array in C# (in the example below int[]).

C# code example ```CS private void doStuff() { int[] arr = { 1, 2, 4, 5, 6, 7, 8 }; List aux; //////////////////////////////////////////////////////////////////////////////////////// // Simple arrays cannot change the number of elements => // Methods with no equivalent, that require conversion between container types // - remove_at // - pop_at // - pop_back // - pop_front aux = arr.ToList(); aux.RemoveAt(1); arr = aux.ToArray(); // - erase aux = arr.ToList(); aux.Remove(11); arr = aux.ToArray(); // - push_back // - push_front aux = arr.ToList(); aux.Add(420); // back aux.Insert(0, -420); // front ////////////////////////////////////////////////////////////////////////////////////// // Random.Shared.Shuffle(arr); // requires framework "net8.0" Array.Sort(arr, Comparer.Create((a, b) => a - b)); arr.All(i => i < 10); arr.Any(i => i < 0); arr.Where(i => i < 10) // filter .Select(i => i + 10) // map .Aggregate("", (acc, i) => acc += $"{i}cm, "); // reduce Array.BinarySearch(arr, 69, Comparer.Create((a, b) => a - b)); arr.Last(); // back arr.First(); //front } ```
Original message > It would also (depending on how it's implemented) make working with Modules and GDExtension more difficult, as `Packed*Array` is different in Modules and GDExtension due to the former just being a `Vector` and the latter being a dedicated interop type, and we aim to make it possible to write code that works both in a module and as GDExtension with minimal modification or `#ifdef` etc.

I can see how this may make the implementation of this proposal harder, nevertheless I'm positive this isn't the only place where these 2 differ.

Original message > `Vector` for example already has `sort_custom` but it doesn't take a `Callable` but a custom sort class, this can't be exposed to scripting, so it would cause issues with handling that parity for example > > Further most of these methods (with the exception of the `pop` methods, `back`, `front`, and `pick_random` methos) couldn't be implemented on `Vector` itself, because you couldn't have it take a `Callable` because it isn't guaranteed to be able to pass the argument. So it'd have to be implemented externally. So it would be messy and complicated. This is why `Vector` doesn't have any `Callable` based methods in it > Further most of these methods (with the exception of the `pop` methods, `back`, `front`, and `pick_random` methos) couldn't be implemented on `Vector` itself, because you couldn't have it take a `Callable` because it isn't guaranteed to be able to pass the argument. So it'd have to be implemented externally. So it would be messy and complicated. This is why `Vector` doesn't have any `Callable` based methods in it

Can you expand on this, please? Why couldn't a Callable take the argument? Also what argument specifically? Are you considering the return value of the Callable?

The way I see it right now, the Implementation difficulty : 1 can be implemented directly on the Vector<T>, and the rest (because they need access to Callable and Variant) in an extensions file that is used to create the bindings for GDScript. I'm not sure how much there is a need for these methods in Modules and GDExtensions, as, AFAIK you are using C++ to write both and such would have access to inner Vector<T> and the std::vector<T>, for which (the latter) the stl already has these mostly implemented (C++20).

AThousandShips commented 1 month ago

you can get the same functionality between

Yes, that's what I said, you can but you have to change your code, that's exactly the issue I'm talking about

I can see how this may make the implementation of this proposal harder, nevertheless I'm positive this isn't the only place where these 2 differ.

So let's not make it harder shall we?

Why couldn't a Callable take the argument

Because you can't convert everything to a Variant, what if you make a Vector<Pair<Object *, Ref<Resource>>>? How would you encode that to a Variant? How about Vector<Vector<Object *>>?

Again this is just part of why this change would involve a lot of fragile glue code that would:

The reason this is relevant is because Packed*Array isn't the same as Vector<T> in GDExtension as I already mentioned, so if you're writing code that can be used both as a module and as an extension you're unable to handle that difference,

Vlad-Zumer commented 1 month ago

Yes, that's what I said, you can but you have to change your code, that's exactly the issue I'm talking about

You don't really have to change the code because, like you said, Packed Arrays don't exist in C#. I don't think that having to use the language features that are not a 1-to-1 mapping is problematic, or a good example of lack of parity between GDScript and C#.

Original message > Because you can't convert everything to a `Variant`, what if you make a `Vector>>`? How would you encode that to a `Variant`? How about `Vector>`?

1) Why not require that the inputs and outputs of the Callables, even in C++ be Variants. What I am proposing is that the Callables from the Packed Arrays methods would take in Variants and output Variants as well.

2) There are already methods on normal arrays that take in Callables which need to have Variant parameters and return either another Variant or a specific type (see examples).

Func Name Return Callable Signature
Array.all bool (Variant) -> bool
Array.map Array (Variant) -> Variant
Array.sort_custom void (Variant, Variant) -> bool
Array.reduce Variant (Variant, Variant) -> Variant

I tested some of these with Callables that do not respect the required signature and some amount of type checking is already being done (when using typed Callables). Why can't this be done for Packed Arrays as well?

Original message > Again this is just part of why this change would involve a lot of fragile glue code that would: > > * Make it struggle with performance and maintainability > > * Make it impossible to write code that can be both a module and an extension, where methods in `Packed*Array` aren't available on `Vector` > > > The reason this is relevant is because `Packed*Array` _isn't_ the same as `Vector` in GDExtension as I already mentioned, so if you're writing code that can be used both as a module and as an extension you're unable to handle that difference,

1) Could this be done/exposed only for GDScript ? My understanding is that GDExtensions and Modules are already quite advanced topics, so small inconsistencies between the 2 shouldn't have much of an impact.

2) I understand the performance concerns issue, but this proposal won't make the existing performance metrics any worse, as it's a purely additive change to the API.

AThousandShips commented 1 month ago

You don't really have to change the code because, like you said, Packed Arrays don't exist in C#

I'll try to be more clear: You have to change your code when you translate code from GDScript to C#, that's what I mean

Why not require that the inputs and outputs of the Callables, even in C++ be Variants

We do, that's exactly what I'm saying, please try read again what I say, the problem is that they require Variant, but you can't make everything into a Variant. And Array is completely different because it always has Variant as a value type, exactly unlike Vector in general, that's why it can't be a method on Vector<T> but has to be a bound method elsewhere, which I explained why that's a bad idea...

The point is that the Packed*Array types aren't designed for what you propose, and they don't need this functionality, and adding it would be costly and messy, so it'd have to be for a very good reason to justify making all that work, and as I've pointed out there's a lot of reasons that would be costly and cause confusion and mess

You are completely ignoring everything I explained, please go back over what I said and try pay more attention to the details

Have a nice day

Vlad-Zumer commented 1 month ago

I am still confused about this variant thing. There's definitely something that I don't fully understnad. Can you possibly provide some code to illustrate the problem?

The way I see it it would work like this:

class Variant;
class Array : public Variant;

class Callable : public Variant
{
    // variadic operator 
    Variant operator()(Variant...);
}

// constrain type T further
template<class T>
class PackedArray<T>
{
    // runtime typechecking like the Array.Filter
    PackedArray<T> filter (Callable method) const;
    void sort_custom(Callable func);
    Array<Variant> map(Callable method) const;
}
AThousandShips commented 1 month ago

I'll give a final explanation repeating myself from above: There's no PackedArray<T> type, it's a Vector<T> internally so it can take any type (it being a Vector internally is the entire reason they exist, as an interop type), not just the specific types, this might be the root of your confusion that you missed that in my multiple explanations, it's not it's own type (outside of GDExtension, which as I've explained causes its own problems)

So if we add this method to Vector<T> it will break if the T type can't be a Variant which is often the case, for example something as simple as Vector<Vector<Node *>> or similar

Only some types can be a Variant and that isn't something that's restricted on Vector

This means that the implementation of filter has to be made separately, which means:

AThousandShips commented 1 month ago

So to summarize a bit and clarify the points I've tried to make here:

So unless you're either 1) communicating with the engine internals or 2) dealing with large data or 3) want to store data efficiently you probably don't need to use Packed*Array, you can still use it if you wish (it absolutely has some benefits, there's nothing wrong with using them outside of that), but the point is that that falls outside the main purpose and what they are designed for.

So if you want a general purpose container you should probably just use Array. This will be further helped by improvements to how type coercion and casting works with typed arrays going forward, it's being hotly debated and worked on as we speak but it's a pretty complicated question so might take some time to fully figure out.

So while there are some benefits to using Packed*Arrays over Array even outside of the intended purpose it is still not intended to be a general purpose container, but a type to aid in doing some core engine things more (far more) efficiently.

That's not to say that conceptually the idea of improving these types in this way is wrong, absolutely not, but the problem comes down to:

So to me, and what I've tried to express here, this isn't a "this idea is bad in itself" but "this idea is something that goes against what has been intended for this feature, and it comes with a great cost that makes it hard to justify"

I hope that makes things clearer, and thank you for this discussion it's been very informative :)

Vlad-Zumer commented 1 month ago

Thank you for your time, and thanks for explaining it again. I was under the impression that Packed Arrays existed both on C++ and GDScript as proper types. If I understand you correctly Packed(<T>)Array in GDScript is just a Vector<T> on the C++ side, meaning only the naming has changed, but not the underlying object type. (Let me know if I'm misunderstanding yet again.)

So unless you're either 1) communicating with the engine internals or 2) dealing with large data or 3) want to store data efficiently you probably don't need to use Packed*Array, you can still use it if you wish (it absolutely has some benefits, there's nothing wrong with using them outside of that), but the point is that that falls outside the main purpose and what they are designed for.

So to me, and what I've tried to express here, this isn't a "this idea is bad in itself" but "this idea is something that goes against what has been intended for this feature, and it comes with a great cost that makes it hard to justify"

I understand that Packed Arrays are not "general purpose", what I'm saying is that their functionality could be improved. To be 100% honest, all this started because I wanted the erase method on it, which can be implemented as is, without doing anything with Variants and Callables (find + remove_at).

Finally considering my new understanding of the problem, I think by using SFINAE and conditional compilation of the missing methods can be implemented just for Variant types. I think something like this (same code below) would work pretty well.

It enables the other methods only for Variant types. I think this also solves the difference between GDExtensions and Modules since the user would get a compilation error when using the Variant only methods on vectors that do not hold Variant values. (PS. This is only an example and can surely be refined to better suit the application.)

Code ```c++ #include #include #include #include //////////////////////// // isVariant predicate //////////////////////// // data holder type template struct isVariant_t { static constexpr bool val = false; }; // short-hand know if a type is variant template constexpr bool isVariant = isVariant_t::val; // utility macro to declare what types are variants #define declareVariant(type) \ template<> \ struct isVariant_t \ { \ static constexpr bool val = true; \ } // declaring variant types declareVariant(int); declareVariant(float); //////////////////// // Placeholders /////////////////// // I don't know the internal structure of these // so I used placeholder types class Variant {}; class Callable { public: Variant operator()(Variant a) { // code for callable return a; }; }; ///////////////////////////////////////////// // Conditional compilation of new methods //////////////////////////////////////////// template> class IVariantMethods; template // non variant holding vector class IVariantMethods {}; template // variant holding vector class IVariantMethods { public: C filter(Callable c) { // "this" IS the container, always const C* container = static_cast(this); return *container; }; // map, reduce, etc. }; template // this inheritance adds methods if needed class Vec : public IVariantMethods, T> { public: Vec() {}; // other constructors + copy/clone // (...) virtual ~Vec() = default; // Vec methods void push(T item) { m_vec.push_back(item); } void clear() { m_vec.clear(); } T& operator[](size_t index) { return m_vec[index]; } const T& operator[](size_t index) const { return m_vec[index]; } size_t len() const { return m_vec.size(); } private: std::vector m_vec; }; template void printVec(const Vec& vec) { std::cout<<"["; for (int i=0; i vInt = {}; vInt.push(1); vInt.push(2); vInt.push(3); printVec(vInt.filter(Callable{})); // OK std::cout<<"\n"; Vec vFloat = {}; vFloat.push(1.0f); vFloat.push(2.0f); vFloat.push(3.0f); printVec(vFloat.filter(Callable{})); // OK std::cout<<"\n"; Vec vStr = {}; vStr.push("string 1.0f"); vStr.push("string 2.0f"); vStr.push("string 3.0f"); // vStr.filter(Callable{}); // Compile error printVec(vStr); // OK std::cout<<"\n"; std::cout<<"Hello Wold\n"; return 0; } ```
AThousandShips commented 1 month ago

meaning only the naming has changed, but not the underlying object type

Yes it's just a wrapper around Vector<T>, it probably got lost in my longer comments above when I said it before :)

I think by using SFINAE and conditional compilation of the missing methods can be implemented just for Variant types.

That'd be very fragile as there's not necessarily any way to directly tell if a type is compatible, it'd also create dependencies that'd be messy (I'd say it might not even be directly possible to even add Callable to Vector as Callable might on Vector, at least as it needs to be able to cast to Variant which does depend on Vector, so you probably wouldn't be able to call a Callable in the vector.h header, and you'd have to because of templates)

So regardless it'd still be something that has to be put in separate methods

erase could easily just be bound, and would make far more sense than most of the other cases IMO without being relevant as a generalization (it's already implemented on Vector

AThousandShips commented 1 month ago

I'm actually preparing adding erase to Packed*Array, it makes sense and should be simple, will see what the tests result in

Done:

erase is also a prime example of a method that doesn't have the properties listed above:


For the remainder I'd make the following classifications:

Must be implemented separately (with all the consequences above):

Requires decisions on how to implement them, as already discussed with the relevant PR implementing it for other cases, these would very likely have to crash when the index is unavailable, as it cannot signal an invalid result, as there's no way to signal that with any data type, you could still store things like Variant() or nullptr:

Same as above but with the added mess that these would have to return a const T & or T & depending, which is even more impossible than the cases above:

Would not have to be implemented separately but should because of introducing major dependencies into all cases using Vector otherwise which would be undesirable, we really don't want to have to include the random generator in everything that uses Vector (which is essentially the entire engine):


Further the pop methods, front, back, and pick_random can easily be worked around with essentially no peformance loss when implemented in GDScript, all, any, filter, and reduce can be worked around easily but with some performance reduction as you'd have to do the iteration and processing in GDScript which would be slower, bsearch_custom can be worked around but isn't very enjoyable, sort_custom is very difficult to work around

The back and pop_back methods would be made much easier if Vector supported negative indices, but that's something that might be unnecessarily costly to add as the index operator is reasonable performant as is and doing that sort of checking might harm that


As you can see several of these issues arise from that Vector is a header only file because it's a template, you cannot implement the code itself outside of the header, so it makes you unable to do a lot of the tricks you can otherwise to avoid introducing heavy dependencies, as is done with Array which handles the randomness internally