Skip to content

table.swapremove - #236

Draft
zermelon wants to merge 8 commits into
luau-lang:masterfrom
zermelon:table-swapremove-rfc
Draft

table.swapremove#236
zermelon wants to merge 8 commits into
luau-lang:masterfrom
zermelon:table-swapremove-rfc

Conversation

@zermelon

Copy link
Copy Markdown

Rendered
This RFC proposes a new table function, table.swapremove, that implements swap-and-pop for an array, with a similar signature to the existing table.remove function.

A previous RFC was made for this request (though it was dubbed as "uremove" rather than "swapremove", and did not return the removed element), but it was not well motivated. This RFC covers various more substantive motivations for this function.

Comment thread docs/table-swapremove.md Outdated
```lua
t[i], t[#t] = t[#t], nil
```
While this idiom is concise, it obscures the intent of performing an unordered removal and leaves the details of the operation to individual implementations. For example, a manual implementation does not return the removed value like `table.remove`, does not consistently validate (namely, throwing an error for non-numeric indices) or floor (for non-integers) the provided index, and may not update the VM's cached array length in the same way as the `table` library. A native implementation could improve performance.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

while this idiom is concise

this disclaimer could probably be removed, table.swapremove is as concise as a bare swap remove

it obscures the intent of performing an unordered removal

does it? many libraries already define swapremove functions to make the intent clear.

may not update the VM's cached array length

i am pretty sure the length will shift down if tbl[last] is set to nil

floor (for non-integers) the provided index

is that the best behavior here? i'd argue for putting "validating arguments" into the drawbacks section, user code not having to do that is probably a good trait

a native implementation could improve performance.

by what meaningful metric

leaves the details of the operation to individual implementations

what details?

@zermelon zermelon Jul 30, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

it obscures the intent of performing an unordered removal

does it?

Yes. I would argue that it is not immediately clear what this does, why it is beneficial, or why it is fast, when one sees this idiom, unless you are sufficiently familiar with arrays or know of swap and pop. if t[i] = value was similarly obscure enough to justify table.insert, I'm quite sure that t[i], t[#t] = t[#t], nil definitely is.

may not update the VM's cached array length

i am pretty sure the length will shift down if tbl[last] is set to nil

Ah; I did not know this. I assumed naively that it would just be table.insert/table.remove (which are the two, evidently non-exhaustive, examples given in https://luau.org/performance/#optimized-table-length)

floor (for non-integers) the provided index

is that the best behavior here? i'd argue for putting "validating arguments" into the drawbacks section, user code not having to do that is probably a good trait

I am trying to preserve parity with table.remove which does precisely this. I believe that general familiarity in implementation and signature would be beneficial. I'm not sure if I would consider this a drawback; it seems like a metric to optimise given we ideally want to maximise parity.

local myFavouriteFruits = {"apple", "banana", "kiwi", "orange"}
table.remove(myFavouriteFruits, 2.4)
print(myFavouriteFruits) -- {"apple", "kiwi", "orange"}

a native implementation could improve performance.

by what meaningful metric

Good point. I haven't implemented it myself, though I presume that adding it natively would help as you don't compute #t twice (which, granted, would typically be O(1), but that ignores that this may still be a large constant). In any case, it was wrong of me to move to performance; I thought it would be at least worth mentioning but clearly not since I have no empirical metrics to back my claim.

leaves the details of the operation to individual implementations

what details?

Whether or not to return the removed element, validation on inputs, and perhaps misleading function signatures. Standardising all of this is beneficial for these reasons. I imagine that whatever reason was used to motivate table.insert over t[#t+1] = value/ t[i] = value, other than immediate clarity, could equally work here.

Thank you for the comments.

Comment thread docs/table-swapremove.md Outdated

A dedicated `table.swapremove` function would provide a single, well-defined abstraction for this common operation, offering behavior that is consistent with `table.remove`. It would also complete the table library by providing an unordered counterpart to `table.remove`. Users may implement swap-and-pop themselves, just as they might implement insertion with `t[i] = value` or append with `t[#t + 1] = value`. The value of `table.insert`, however, is not that it enables something previously impossible or impractical, but that it provides a clear, consistent, and optimised implementation of a common operation. This RFC proposes doing the same for unordered removal.

Rust (`Vec`'s `swap_remove`), Zig (`ArrayList`'s `swapRemove`), and Unreal Engine (`TArray::RemoveAtSwap`) implement this.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

these are all statically typed languages with explicit array features. not that they're bad examples, but even fastcalls aren't free in the vm and validation would be kinda scary

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

This is a good point. Drawing inspiration from statically typed languages isn't necessarily bad though, as you say. I wouldn't move to make it a fastcall, especially given table.remove isn't.

Comment thread docs/table-swapremove.md Outdated

`table.swapremove` behaves similarly to `table.remove`. Namely:
- Updates the VM's cached value for #t
- Does nothing (silently) on integer indices which have a `nil` value, floors non-integer indices, and throws errors for non-numeric indices

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

updates the vm's cached value for #t

might it be better to use maxn(t)

Does nothing (silently) on integer indices which have a nil value, floors non-integer indices, and throws errors for non-numeric indices

the first two here (especially the latter) are not immediately obvious to me. why should we floor non-integers instead of erroring or silently cancelling?

Comment thread docs/table-swapremove.md Outdated
- Updates the VM's cached value for #t
- Does nothing (silently) on integer indices which have a `nil` value, floors non-integer indices, and throws errors for non-numeric indices
- Uses `pos = #t` if `pos` is omitted
- Returns the removed element

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

what if you want access to the element that got placed into the removed slot? what if you want access to the length of the array? as someone who has worked on ECS implementations, object pooling setups, and reactive stuff... those are all useful pieces of information that i wouldn't get from this function, it'd make the function almost exclusive with any of those usecases

@zermelon zermelon Jul 30, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

what if you want access to the element that got placed into the removed slot?

You used the index in table.swapremove(myarray, **index**), I think you can quite easily do myarray[index] afterwards. Even if it is the last element which you swapped-and-popped, myarray[#myarray] would (correctly) return nil.

what if you want access to the length of the array?

#myarray could be of use.

those are all useful pieces of information that i wouldn't get from this function

Why would you need this information? If we applied this logic to table.remove, you are effectively asking for something akin to "which element got shifted down first / last" and "how do I get the length of the array after removal". These are all trivial things to implement for a user and are beyond the scope of either of these functions. I do not believe these should be added as returns to this function.

Comment thread docs/table-swapremove.md
## Drawbacks

It is yet another function to the table library that has a relatively simple implementation, like `table.insert` or `table.remove`, that users could reasonably implement themselves as discussed earlier.
It is not immediately apparent to an unfamiliar user whether or not `swapremove` would return the swapped element or the removed element.

@nnullcolumn nnullcolumn Jul 30, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

It is not immediately apparent to an unfamiliar user whether or not swapremove would return the swapped element or the removed element.

why not both?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

That would unnecessarily complicate the return signature, and turn it into (T?, T?) - the order isn't clear unless you look at documentation. Furthermore, most users who are indeed familiar with swapremove expect it to return only the removed element. The swapped element can easily be determined as I suggested previously. Also, in the case where we remove the last element (setting the positional argument to #t, or omitting it), the swapped element could equally be nil (as you don't really need to swap it with anything), or the last element itself, depending on implementation. This ambiguity should be avoided.

Comment thread docs/table-swapremove.md
Comment on lines +47 to +49
## Alternatives

We could simply not implement this function. Users could continue to use the swap assignment.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

an explicit array datatype would be a potential alternative

Comment thread docs/table-swapremove.md Outdated

The proposed signature would be very similar to that of the existing `table.remove` (using the style of the [current documentation](https://create.roblox.com/docs/reference/engine/libraries/table#remove)):
```lua
table.swapremove(t: {any}, pos: number): Variant

This comment was marked as duplicate.

Comment thread docs/table-swapremove.md Outdated
## Design

The proposed signature would be very similar to that of the existing `table.remove` (using the style of the [current documentation](https://create.roblox.com/docs/reference/engine/libraries/table#remove)):
```lua

This comment was marked as duplicate.

Comment thread docs/table-swapremove.md Outdated
Comment on lines +9 to +21
Swap-and-pop is a very common algorithm used to remove an element from an array in O(1) by replacing said element with the final element, and then removing that last element. This is significantly faster, especially for larger arrays, than a regular `table.remove`-like operation, which must shift all subsequent elements to preserve a contiguous layout. It is used frequently in places like ECS implementations, particle systems, object pooling, and plenty more. A brief demonstration is shown below:

```lua
local myFavouriteFruits = {"apple", "banana", "kiwi", "orange"}
table.swapremove(myFavouriteFruits, 2)
print(myFavouriteFruits) -- {"apple", "orange", "kiwi"}
```

Currently, to perform swap and pop, one might usually do the following:
```lua
t[i], t[#t] = t[#t], nil
```
While this idiom is concise, it obscures the intent of performing an unordered removal and leaves the details of the operation to individual implementations. For example, a manual implementation does not return the removed value like `table.remove`, does not consistently validate (namely, throwing an error for non-numeric indices) or floor (for non-integers) the provided index, and may not update the VM's cached array length in the same way as the `table` library. A native implementation could improve performance.

@nnullcolumn nnullcolumn Jul 30, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

the biggest thing unsaid in this motivation section is that it handles "the item provided is the last item already" pretty nicely, whereas many libraries may specialize their code for that or even have bugs for it. thats a nice detail i didnt notice earlier

Comment thread docs/table-swapremove.md
Comment on lines +42 to +46
## Drawbacks

It is yet another function to the table library that has a relatively simple implementation, like `table.insert` or `table.remove`, that users could reasonably implement themselves as discussed earlier.
It is not immediately apparent to an unfamiliar user whether or not `swapremove` would return the swapped element or the removed element.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

i think we should mention in the drawbacks section that this encourages a pattern of several subsequent removals to parallel arrays, which may be slower or even lead to unsafe behavior

-- this is fine
table.swapremove(entites, row)
-- this is instantly slower in theory than a user implementation, and it also gets very yucky if the parallel array turns out to have holes. if we assume that the arrays are parallel in user code, neither issue would surface
table.swapremove(column, row)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Good point.

Comment thread docs/table-swapremove.md Outdated

## Design

The proposed signature would be very similar to that of the existing `table.remove` (using the style of the [current documentation](https://create.roblox.com/docs/reference/engine/libraries/table#remove)):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

using the style of the current documentation)

This is not Luau documentation, and is not a Luau type signature. It's not good documentation for Roblox to be producing since it is fundamentally incorrect, and it definitely does not belong in our RFCs.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

I apologise. In future, I will use the proper signature style.

Comment thread docs/table-swapremove.md Outdated
```

Currently, to perform swap and pop, one might usually do the following:
```lua

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
```lua
```luau

The programming language is Luau.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

I apologise - I didn't know if Github's MD files had support for luau codeblock rendering. Probably should have checked instead of naively assuming.

Comment thread docs/table-swapremove.md Outdated

Swap-and-pop is a very common algorithm used to remove an element from an array in O(1) by replacing said element with the final element, and then removing that last element. This is significantly faster, especially for larger arrays, than a regular `table.remove`-like operation, which must shift all subsequent elements to preserve a contiguous layout. It is used frequently in places like ECS implementations, particle systems, object pooling, and plenty more. A brief demonstration is shown below:

```lua

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
```lua
```luau

The programming language is Luau.

Comment thread docs/table-swapremove.md Outdated
## Design

The proposed signature would be very similar to that of the existing `table.remove` (using the style of the [current documentation](https://create.roblox.com/docs/reference/engine/libraries/table#remove)):
```lua

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
```lua
```luau

The programming language is Luau.

Comment thread docs/table-swapremove.md Outdated

The proposed signature would be very similar to that of the existing `table.remove` (using the style of the [current documentation](https://create.roblox.com/docs/reference/engine/libraries/table#remove)):
```lua
table.swapremove(t: {any}, pos: number): Variant

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This should be an actual Luau type signature.

table.swapremove<T>(table: {T}, index: number): T

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

i think the return signature would probably need to be optional

Comment thread docs/table-swapremove.md Outdated
table.swapremove(t: {any}, pos: number): Variant
```

`table.swapremove` behaves similarly to `table.remove`. Namely:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Comparison to table.remove doesn't really help explain the semantics here. You should just explain what the swap-and-pop algorithm does directly. That's the semantics of the function. That is something like:

table.swapremove will remove the element at the given position by moving the last element of the array into its position, and then return the element that was at the given position before the swap.

Perhaps even being more explicit that the last index is then set to nil.

Comment thread docs/table-swapremove.md Outdated
- Uses `pos = #t` if `pos` is omitted
- Returns the removed element

We propose using Rust and Zig's `swapremove` nomenclature (in luaucase), as opposed to something like `swapandpop` or `moveandpop`, or Unreal Engine's `RemoveAtSwap`.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I don't think this is really a value-add of a sentence. It's already obvious that you're proposing the name you're proposing, and we don't really consider "it shares a name with another language" to be a strongly compelling reason to use a name. swapremove is a fine name because it's a description of the operation, and that stands on its own.

Comment thread docs/table-swapremove.md Outdated

`table.swapremove` behaves similarly to `table.remove`. Namely:
- Updates the VM's cached value for #t
- Does nothing (silently) on integer indices which have a `nil` value, floors non-integer indices, and throws errors for non-numeric indices

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think this is a drawback of this design that is not described in the drawbacks section. While it may be the reasonable choice to no-op on arrays with holes (you don't make any argument for why it is, so it's hard to say!), this is also very readily a footgun. The alternative of swapping the last element to there and returning nil feels viscerally like it'd meet my expectations more, for instance. You don't have to propose that on my account, but the RFC should actually explore and discuss this design decision.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Great point. I didn't actually think about that; I agree that it would make a lot of sense to perform the naive swap-and-pop and do exactly as you suggest for a sparse array.

Comment thread docs/table-swapremove.md Outdated
```

`table.swapremove` behaves similarly to `table.remove`. Namely:
- Updates the VM's cached value for #t

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

You do not need to describe tiny implementation details like this. I don't think anyone expects removing an element from the array to leave the size of the array unchanged.

Certainly though, you don't need to describe them in this particular way. I'd encourage you to think about and author proposals in terms of what you see as a user of the language, not the implementation details. It doesn't matter to a user that there is a VM for Luau or that the value of the length of an array is cached. The observable behavior at a language level is that the array's length is one smaller after removing the element.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

You do not need to describe tiny implementation details like this. I don't think anyone expects removing an element from the array to leave the size of the array unchanged.

i think it was included because the author has some kind of misconception about what is actually going on with length, given the earlier mention of:

may not update the VM's cached array length in the same way as the table library.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

You do not need to describe tiny implementation details like this. I don't think anyone expects removing an element from the array to leave the size of the array unchanged.

Will do, haha :)

Certainly though, you don't need to describe them in this particular way. I'd encourage you to think about and author proposals in terms of what you see as a user of the language, not the implementation details. It doesn't matter to a user that there is a VM for Luau or that the value of the length of an array is cached. The observable behavior at a language level is that the array's length is one smaller after removing the element.

I'll amend the RFC significantly given to address this and all other points. I think I'll also remove the tag at the end of the motivation section about performance since I personally don't have anything to support my claim, and it is, as you say correctly, rather vague.

Comment thread docs/table-swapremove.md Outdated
```lua
t[i], t[#t] = t[#t], nil
```
While this idiom is concise, it obscures the intent of performing an unordered removal and leaves the details of the operation to individual implementations. For example, a manual implementation does not return the removed value like `table.remove`, does not consistently validate (namely, throwing an error for non-numeric indices) or floor (for non-integers) the provided index, and may not update the VM's cached array length in the same way as the `table` library. A native implementation could improve performance.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

A native implementation could improve performance.

How? Just stating this without an explanation for the mechanism by which this would happen is not compelling.

@Kampfkarren

Copy link
Copy Markdown
Contributor

I am not convinced that Luau needs to add something that can amount to three lines in a module. Performance as an argument is unremarkable as an argument because this pattern is both very easy to detect if we happened to have a special way to make this faster, and function inlining/cross function inlining are an obvious general solution.

@zermelon
zermelon marked this pull request as draft July 30, 2026 09:24
@zermelon

zermelon commented Jul 30, 2026

Copy link
Copy Markdown
Author

I am not convinced that Luau needs to add something that can amount to three lines in a module.

I've modified the RFC to address a number of excellent points made. I also don't think this logic stands particularly well in the face of table.insert or table.remove; as I explain in the RFC, the value of these is not in the fact that they are impossible to implement manually, but in standardisation, prevention of unexpected mistakes like providing an index outside of [1, maxn], and the returning of the removed value (which is beneficial for cleanup). You're right-on about performance though.

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.

4 participants