SE-0527: RigidArray and UniqueArray

i’m a -1 on adding RigidArray and UniqueArray to the standard library.

i think that the problem they are trying to solve is a big one, i appreciate that there is an effort being made to solve it. i have some questions about how this might unfold in practice, when these types, as proposed, collide with real-world use cases.

i. you can’t do much without Sequence.

of all the concerns i have, this is the most minor. because i think it has been well-communicated what the vision and the roadmap here is, and that people will just need to accept writing a little more boilerplate in the short term to do basic operations on UniqueArray. still, i feel that it will make a bad first impression on a lot of folks if UniqueArray ships with the standard library in “half-baked” form, even if there has been adequate communication with respect to its limitations.

for-in loops get a lot of attention because they are fascinating from a language design perspective, but i think the long tail of Sequence API, like reduce, joined, etc. is going to be the thing that is really going to kill the mood for a lot of users.

the “obvious” remedy here is to just vend all that stuff as concrete API until the Sequence support gets fleshed out, but standard libraries are forever, so that’s only gonna fly if UniqueArray stays in a package, like swift-collections, where it lives right now.

but that’s more of a customer-relations thing, it doesn’t really affect how i would use UniqueArray in my own work.

ii. UniqueArray just doesn’t compose, and it’s not really clear what the right patterns here are.

i think one of the most serious points where developers are going to struggle with UniqueArray is with upcasting of array elements. it’s easiest to illustrate this with an example using Optional promotion:

struct UniqueArena<Object>: ~Copyable where Object: ~Copyable & UniqueObject {
    var keys: OrderedSet<Object.ID>
    var values: UniqueArray<Object>
}

see if you have that, you’re definitely going try writing an API like this:

extension UniqueArena where Object: ~Copyable {
    subscript(id: Object.ID) -> Object? {
        _read {
            if  let i: Int = self.keys.firstIndex(of: id) {
                yield self.values[i]
            } else {
                yield nil
            }
        }
    }

and you’ll quickly realize that doesn’t work at all. and unlike the missing Sequence API problem, there’s really no good answer to what the additional code you need to write actually is.

one thing you could try is swapping the value to the end (which i don’t like, more on that later), popping it off, yielding the value, and then re-appending it and swapping it back in a defer block. but those are all mutating operations, which means _read becomes mutating _read, which is a really weird API, because now you’re doing operations on the UniqueArray that are semantically reads, but look to the compiler like writes. this is going to collide headfirst with Swift’s system of immutable-by-default value semantics.

or you could fatalError on missing keys, that would handwave away the problem, but that would basically mean there is no way to vend a “friendly” (ordered) dictionary type, any such type is going to be a “hard” dictionary that crashes on invalid key access.

or you just give up on the idea of storing the buffer pointer(s?) inline, and kick the whole thing into a class that you wrap in a COW struct so that reads look like reads and not writes. you end up with a thing that looks a lot like Array, but with the Element: Copyable restriction lifted, which is what i suspect many users are actually looking for when they reach for UniqueArray.

iii. UniqueArray might lead people down the wrong path, and teach lessons that will be hard to unlearn

it helps to organize (blame!) potential solutions by who needs to change and who gets to stay put.

one thing you could single out for blame here is yield. a lot of these problems become significantly less daunting if we say that yield can hop through closure boundaries, and we just classify it as “unsafe” if that closure never actually gets called. yes that would be a Separate Feature from UniqueArray, but if we presume that yield is the thing that needs to change, then that implies that generalized yield is a prerequisite for UniqueArray to be viable, in the same way that RBI was a prerequisite for Concurrency to be viable.

alternatively, you could say the developer’s patterns are at fault, that copyless Swift code necessitates a different way of laying out data structures than we are used to. for example, we might condemn UniqueArray<T> as an antipattern, and say that UniqueArray<T?> should be everyone’s starting point, since UniqueArray<T?> is considerably easier for the compiler to swallow than UniqueArray<T>.

similarly, we might accept that moving, i.e. swapping, is a necessity when writing copyless code, that developers need to get used to the paradigm of swap-pop-push-swap and that this is a skill that people need to level up in. but that’s a tall commitment, a lot of tasks like “remove the k elements from this UniqueArray at these indices in O(n) time” become extremely complex under the swapping paradigm, and like UniqueArray<T?>, it locks you into patterns that are really hard to climb down from if they later turn out to be local minima. this is qualitatively different from just adding local polyfills for Sequence API.

the bottom line here is that there are a lot of unanswered questions as to how we’re actually expecting users to derive net value from UniqueArray, which is a signal that we haven’t accumulated the necessary experience to inform what API it should have, what yet-to-be-shipped language features it depends on, and what responsibilities belong to the developer when using that API. so i just don’t think that UniqueArray is ready to graduate from swift-collections yet.

8 Likes

In my view, the Containers module is a way to manage the sheer number of new container implementations. We started with two array types (Array and ContiguousArray); we're now proposing RigidArray and UniqueArray, and we have plans to also add SmallArray as well as an in-place stored resizable array type. I also expect that we'll propose a multitude of new set and dictionary types (ownership aware, ordered, sorted), ring buffer types, some sort of linked list representation, priority queues, etc. etc.

Of course, we can keep adding all these to the default namespace of every Swift program. But I do think that at some point the convenience/discoverability benefits will turn against us, and the sheer volume of immediately accessible API will become overwhelming/paralyzing.

That said, it may not be unreasonable to say that RigidArray and/or UniqueArray will be prominent enough to deserve getting defined in the Swift module, deferring the creation of the Containers module until we start to propose more "exotic" containers. However, I don't think we have gained enough experience using these to say that with confidence.

If we put these array types in Containers now, then we still have the option to sink them into the Swift module when (and if) it becomes clear they are universally needed. If we put them in Swift today, then that decision is final.

The questions on whether to put the new types in a module and whether to provide both of them are, of course, related. If we define a Containers module, then it certainly lowers the cost of entry, as things we put there will be nowhere as loud as anything we put in the Swift module.

As I see it, the Containers module would be roughly along the same lines as the Synchronization module: it collects constructs that are important enough to belong in the Standard Library, but that aren't universal enough to justify putting them in the default namespace.

  1. Do people generally think that introducing the Synchronization module was a good idea that we should replicate, or is the explicit import viewed as an unnecessary complication?

  2. Does RigidArray and UniqueArray feel more like advanced/niche types like Mutex or Atomic, or do they feel more like universally needed types like Int or Bool?

  3. Would SortedSet (in-memory B-tree) or RigidDeque (fixed-capacity ring buffer) belong in the default namespace of every Swift program?

  4. If we had to propose ContiguousArray today, would we put it in the Swift module?

In my opinion, Synchronization was a good idea, and I think we definitely need a Containers module for "less prominent" data structures like RigidDeque.

I am honestly unsure whether RigidArray/UniqueArray should be on equal footing with Array. RigidArray in particular is geared specifically for an audience who wants full control; is it really a hardship for them to explicitly import struct Containers.RigidArray, or would they actually prefer that? UniqueArray clearly has a wider audience, but is that wide enough for inclusion in the standard namespace?

That is interesting! I have found the exact opposite -- I find myself reaching for RigidArray rather than UniqueArray in the majority of cases, because it forces me into the right mindset for high-performance work. (I may be something of a control freak, but I'm pretty sure I'm not alone.)

As I understand it, Swift's ownership model exists to allow Swift use in contexts that require highly predictable performance, to a degree that is not reliably achievable in our classic copyable/escapable model. The proposal's rationale describes why RigidArray is the type that achieves that mandate. To me, it feels like UniqueArray is in a somewhat weird middle ground, where I want to use some ownership features, but I don't care enough about performance to want to engage in a full memory analysis.

Obviously UniqueArray is the easier type to use, by far -- the difference is not even close. I also agree that it is at the right level of abstraction for most developers who just need an "array with noncopyable elements". But of the two proposed types, RigidArray is the variant that takes the mandate of predictable performance seriously, and I do believe it is important that we provide it.

To reiterate from the proposal, these are the benefits RigidArray provides that UniqueArray cannot:

  • Bounded allocation. In the absence of explicit reallocate (or resize) calls, RigidArray allocates exactly once. We can precisely reason about its memory footprint: we can document it, we can budget for it, we can rely on it. UniqueArray's geometric growth means it may hold up to ~1.5× more capacity than its count at any moment (and that's subject to change), and we cannot prevent it from reallocating — reserveCapacity sets a floor, not a ceiling.

  • Deterministic operation complexity. Every RigidArray operation runs in bounded time with no hidden allocations. UniqueArray.append has amortized O(1) complexity, but individual calls can and do have linear cost whenever the buffer grows. For real-time code, embedded systems, or any context where latency spikes are unacceptable, that unpredictability matters.

  • Trapping on overflow. RigidArray traps when its capacity is exceeded, making capacity violations very obvious, and directly actionable. On UniqueArray, the same situation silently reallocates. If a capacity overflow indicates a bug or a violated invariant, we want the program to stop, not quietly continue with a larger buffer. (Granted, it would be much nicer if capacity overflows would be caught at build time; however, we don't have tools for automated static capacity analysis, and it seems unlikely we would gain them.)

I'm not suggesting that developers should generally prefer RigidArray to UniqueArray any more than they should prefer UniqueArray to Array. My position is that RigidArray fills an important niche that UniqueArray doesn't, and that makes it a crucial type for a certain audience. (Just like UniqueArray fills an important niche that Array doesn't.)

Sure! As long as we are very careful to use UniqueArray just right, we can avoid hitting unexpected reallocations. We can even add custom extensions or wrapper types to help holding it right.

But it's also true that as long as we are very careful to use Array just right, we can avoid hitting unexpected bridging overhead, copy-on-write copies or reallocations. We can even add custom extensions or wrapper types to help clients hold Array correctly.

Over the years, the feedback I got from performance-minded folks has consistently been that a dynamically resizing construct is not good enough. It is much preferable to not have complexity traps at all, rather than to try to avoid hitting them by careful API filtering. (Issue #309 in swift-collections is just one example of this.)

As I understand it, the purpose of our work on the ownership model (of which this proposal is a part of) is to allow Swift developers who need predictable performance to be able to achieve that without having to tiptoe around hidden API traps. We allow intrepid developers to opt into using a language variant that gives them explicit control over performance matters, in exchange for sacrificing some expressibility/convenience.

To me, it is very clear that in some contexts, data structures with implicit reallocations would be considered performance traps. Therefore, to fulfill the ownership vision, I think it is important that we provide fixed-capacity containers so that it is possible to write Swift programs with predictable memory use.

I do believe that fixed-capacity containers should not be treated as an afterthought, relegated to live in packages outside of the Swift Standard Library. We want to entice developers facing low-level/high-performance/memory-starved/realtime problems to choose Swift; we probably should not treat the data structures that cater specifically to them as second-class citizens.

Additionally, I have a "soft" concern about just how little weight this forum puts on constructs shipping in packages. Fixed-capacity containers have some interesting constraints that make them distinct from dynamic containers on the API level. (For example, they will not come with direct initializers that take a sequence with an indeterminate count, while dynamic containers are of course easily populated from such.) This makes it important that we have at least one fixed-capacity container on hand when it comes to designing ownership-aware container protocols. By relegating the more pedantic types into a package, it becomes all too easy (and very tempting) to ignore them when we're discussing the protocol abstractions. Abstractions that assume and enforce dynamic allocations would prevent fixed-capacity containers from gaining advantage of them -- such as preventing some generic algorithms from working, even if they would have no performance concern.

My final argument is also a soft one. UniqueArray's implementation inherently includes RigidArray's operations. It tickles me that we can factor the code in a way that exposes both array types, with UniqueArray's operations simply forwarding to its RigidArray storage when they have enough capacity. We get this for free -- with minimal/no code size overhead and zero runtime performance impact. There is an elegance and simplicity to this that pleases me, and I see it as a signal confirming that we're proposing "natural" abstractions, cutting with the grain, not against it.

11 Likes

i think that Synchronization as a separate toolchain module was an excellent idea, and moreover, i think Synchronization itself was probably the single most well-executed feature addition the Swift project has conducted in years. that module shipped in fully-baked form, it had a coherent “How Do I Do X” story, and it meshed remarkably well with existing constructs in the language.

6 Likes

I do with the Swift standard library was broken up into many more smaller modules, but for development-oriented reasons. Mainly, the Swift module takes a long time to build when doing toolchain work, and the more we add to it, the longer it's going to get. Without other Swift compiler improvements, it's a monotonically increasing endeavor.

This doesn't matter to most people because they're going to be using toolchains where the prebuilt module and library are already included. But for Linux development, we have to build our toolchains from source using Bazel (so we build the stdlib and then check in the resulting static library, and link to that when compiling other Swift code), and I worry that we'll start to creep closer to our mandatory per-action timeouts. We have ways to shard traditional compilations into multiple actions, but since the standard library is built with WMO, that's not an option.

Since we statically link everything, in an ideal world we would just compile the standard library from source on demand in our build graph like any other target, but even with great caching, having a multi-minute action wedged at the beginning of the build's critical path is a non-starter.

Alas, these are all such specialized concerns that it's hard to imagine that they'd have much luck motivating large-scale changes to the standard library.

3 Likes

I share the same perspective.

In early days having to explicitly import the _Concurrency module was a bit annoying, but the experience with the Synchronization module has proven that separating less-ubiquitous, specialized constructs can be very beneficial. It keeps the default namespace clean and prevents developers from being overwhelmed by rarely needed APIs.

Most developers on iOS or macOS will very rarely need these new [Rigid/Unique]Array. For those who do, importing a dedicated Containers module is a small cost compared to the clarity it provides. It also signals that these types are specialized and require more deliberate consideration when used, which is important for performance- and memory- sensitive programming.

That said, some collection types clearly deserve to be in the default namespace. Deque, OrderedSet, SortedSet and OrderedDictionary are widely useful abstractions and align with the everyday needs of developers, so they should be included in the Swift standard library default namespace. This way, common tasks are convenient, while niche or advanced constructs like RigidArray or UniqueArray remain discoverable without cluttering the standard namespace.

Separating modules also gives us flexibility for the future. If we later determine that RigidArray or UniqueArray are universally needed, we can promote them into the standard library without breaking existing conventions. In such a way we can evolve incrementally while maintaining a manageable API surface.

So I suggest to use default namespace for widely useful collections and a Containers module for specialized, ownership-aware or fixed-capacity types. This approach balances discoverability, usability and performance-conscious design.

Yes. If we only have UniqueArray we will be forced to insert preconditions everywhere to check invariants, which increases the risk of subtle bugs and runtime errors.

I think a better approach is to provide an array type that enforces fixed capacity at the type level, similar to InlineArray, while still allowing a dynamic element count up to that capacity. This would give developers compile-time guarantees about memory allocation limits, reduce hidden reallocations and make high-performance, predictable code easier to write.

This approach aligns with Swift’s goals of "safety, expressivity and performance predictability".

3 Likes

there is a compiler crash on 6.3.1 and main that affects any UniqueArray of optional element type.

the crash doesn’t require UniqueArray to reproduce, but UniqueArray is affected.

struct T: ~Copyable {
    let x: Int
}
struct Unique: ~Copyable  {
    let value: T?
}
extension Unique {
    subscript() -> T? {
        _read { yield self.value }
    }
}
func foo(storage: borrowing Unique) {
    storage[]?.x
}

Will it be possible iterate a UniqueArray while consuming all elements e.g. to merge to UniqueArray's with a custom precondition per elements:

struct Item: ~Copyable {...}
func merge(destination: inout UniqueArray<Item>, source: consuming UniqueArray<Item>) {
    for item in source { // some form of iteration that consumes each element
         if !item.isEmpty {
               destination.append(item)
         }
    }
}

I have just profiled a project where we have this pattern and currently use Array. This "works" for Array but introduces a copy of item per iteration. The trace shows more copies of Item in other parts of the code and I would like to make Item ~Copyable but we would then need to be able to properly express this pattern where we merge two collections of them together (with a precondition).

It'll probably be a long time before you can do this with for/in, but you can build it inefficiently as "reverse, then pop until empty", or more efficiently but complicated-ly and callback-ly via edit and then withUnsafeMutableBufferPointer. So we're not locking ourselves out of anything, and adding a manual "convert to non-copyable iterator" operation is likely to end up duplicated with however we eventually want to change for/in for arbitrary consumed lowercase-c collections.

1 Like

To bike shed, UniqueArray sounds like it's an ordered set.

4 Likes

In the course of reviewing SE-0525, the language steering group noted that the two proposals have slightly divergent naming for very similar APIs: here, append(addingCount:initializingWith:) and, in the other proposal, append(elementCount:initializingWith:).

Of course, there may be unique considerations to each case that merit a divergence, but we're keen on further feedback about how these APIs are named given that both proposals are fresh and the discrepancy hasn't been thus far brought up.

Those with feedback can, of course, continue to comment here but also over in the SE-0525 announcement thread.

3 Likes

I agree, I think we should remove the keepingCapacity argument altogether. Thank you!

2 Likes

I am a big proponent of adding UniqueArray to the standard library and enthusiastically endorse this proposal.

But don't believe it's necessary to add a second near-identical type of RigidArray to the standard library, as the need for it is fairly niche IMO and not something many developers – even intermediate ones who know they need something other than a CoW array – should reach for.

That is not to say it isn't useful for some cases, but I think that means it is better served as a package, at least until the patterns for its use are better understood and, potentially, we have more language and library features to support it.

Just to be clear: RigidArray and UniqueArray have the same performance characteristics, so reaching for either will get you the performance you need. Despite having no ability to resize, it is not faster, because it still has to check capacity.[1] It's just that when that capacity is reached, it traps instead of branching into resize code.

And the alternative proposed to using UniqueArray just right is RigidArray, a type that you must use just right or it traps. I am not seeing the big win here. You must use both very carefully, but with one you pay a price in unexpected allocations, and in the other, you pay a price of your code trapping.

Now we have plenty of places in the standard library where Swift can trap if you hold it wrong. But in those cases, the traps happen when there is no reasonable way to proceed. There is no answer to what to return when you subscript out of bounds, or force unwrap an optional. The program cannot continue because what would it do?[2]

Needing to reallocate a heap-allocated buffer, that is able to be resized (RigidArray allows this, just not implicitly) is very different. There is a reasonable way for the program to proceed. This type only makes a different choice as to how you debug when that happens when you didn't want it.

It's not quite true. Bridging overhead of Darwin's Array cannot be avoided in some cases and that's why we have ContiguousArray. Yes, with care you can avoid reallocation costs, and you can avoid the accidental copy-then-write cost of Array. But you sometimes cannot avoid the cost of the uniqueness check, especially in use cases where it can't be mitigated by hoisting.[3]

Even then, I think the comparison with "prefer a noncopyable array, because you can't mess up" is not appropriate for the choice of "I don't want to accidentally reallocate". Because when you make the choice to switch from Array to UniqueArray you get a big win: guidance at compile time. The compiler will stop you unintentionally making copies, accidentally triggering copy on write. That's massive, and can make all the difference when someone wants the compiler to guide them to a better result.

RigidArray does not have the same benefit. The compiler is completely unaware. All it can do is "force [you] into the right mindset for high-performance work." That is not a good enough reason to add a whole separate type to the standard library.

There may be a path to some genuine compile-time checking of unintentional allocations. Clang now supports function effect analysis using a nonallocating (and nonblocking) annotation. This allows for annotations to guarantee paths where no allocations can take place. This would be more in line with an approach that used different methods on the same UniqueArray type that didn't reallocate on append, rather than a whole different type. This approach isn't without its problems (yet more function coloring, boo hiss) but it's something that at least should be discussed further before making the type-versus-method decision.

Finally, I don't think it's been mentioned here, but I think we need to understand the generic programming model for "non-allocating containers" before we decide whether the best approach here is "make non-reallocation a property of the type" versus "make non-reallocation a property of the methods". Imagine a protocol hierarchy like the one way have today where RangeReplaceableCollection captures the ability to append. Some algorithms will be written assuming automatic resizing, and will blow up when passed certain inputs. Presumably the win here is you find out nice and quickly that this can happen, in a way that didn't involve firing up a memory profiler to learn about it. Except you better hope it happens deterministically, and not rarely in production. Which would you rather in your audio processing code... a trap in the wild, or some bug reports of hard to track down glitching. It's unclear to me.

So I think I'd like to see exploration of the space for the future "range replaceable" protocol before we decide that type-based and not method-based restriction is appropriate. In a way, this is a mirror to this concern:

I'm not concerned we will forget about this type when it comes to the evolution proposal for this protocol, in fact I expect it will feature prominently in that discussion whether it's in the standard library or in a package. In the mean time, it can exist in a package and demonstrate its usefulness from there.


  1. there's a slim chance that because the check is followed by the trap, not a branch to a cold path resize, that it can be handled better by the compiler or CPU. Strong evidence is probably needed of this performance edge before offering it as an option to programmers on that basis. ↩︎

  2. Integer overflow is maybe a little different. There is a, I won't call it reasonable, but an answer at least, which is to wrap instead. But still, I don't think this is a comparable situation. ↩︎

  3. And even hoisting doesn't mitigate the binary size cost. In fairness, there is a small chance that in embedded code that truly is after every last drop of binary size, exclusively sticking to RigidArray means not needing to emit the resize code at all, but that code can be shared program-wide, whereas the uniqueness check is inlined at each call site. ↩︎

11 Likes

I just want to echo this. UniqueArray could be a very confusing name for those unfamiliar with ownership concepts in swift. At first glance it really does bring to mind an ordered set.
Some ideas (perhaps not great ones):
UniquelyOwnedArray
OwnedArray
NoncopyableArray
ExclusiveArray

[edit] Ultimately naming isn’t really a big concern to me though. I would rather go ahead with UniqueArray than spend endless posts litigating it :slight_smile:

1 Like

To me, NoncopyableArray is the most direct and understandable name – much better than UniqueArray . The term “unique” is ambiguous: does it mean the array itself is unique across the whole module, or that its elements are unique? NoncopyableArray makes the intended semantics immediately clear.

4 Likes

UniqueArray can contain copyable elements, so NoncopyableArray wouldn't be a great fit for a standard name in my opinion.

However, I do agree with @ben-cohen 's notes and that "unique" is ambiguous (even I didn't know what that meant when first researching it, which is why I was drawn more to RigidArray).

3 Likes

I can see why some may conflate UniqueArray with Set. And thus there seems to be two ways to interpret the array type’s name: describes the array itself; describes the contents.

If we analyze all collection/sequence type names and ask which interpretation is used, what would be the results?

If it’s 50/50, then we have a problem. But hopefully it’s all one way or mostly one way.

1 Like

We have a name for that type, it's OrderedSet in the Collections package (Set doesn't have Array-ness, and so I would not describe it as an array with unique elements). I can see my way to someone maybe calling such a type a "uniqued array" or "array of unique elements", but I have a hard time imagining someone being confused aboutUniqueArray for very long.

6 Likes

Wanted to cross post and say @glessard posted about this naming discussion here: [Accepted with modifications] SE-0525: Safe loading API for RawSpan - #8 by glessard What we end up with on either proposal should be adopted by the other.

1 Like

I think you put it best that the copy-on-write data structures are the right default and what we as a project want people to reach for first when learning the language. Making these types less discoverable by sequestering them in a separate module is almost exactly the purpose. We don't want new developers to run face to face with 4 array types (Array, InlineArray, RigidArray, UniqueArray), potentially 4 Set types, Dictionary types, etc. The separate module helps enforce the primacy of Array, Set, and Dictionary. (And as I write I recognize that InlineArray puts a thorn in this argument, but arguably it should be referenced by its sugar more than its literal name most of the time).

1 Like

Personally, I don’t have a problem with the name of UniqueArray. It states what it does pretty clearly, and I think any confusion with sets will be cleared up almost immediately after realizing that Set is already a thing.

I agree with what has been said before about how RigidArray is pretty unnecessary. I would much prefer the program to slow down slightly and reallocate rather than just crash.

On the Containers Module though, I like the idea, but as long as we only add this one more array type, I don’t think it is necessary because UniqueArray is actually a generally useful concept. I personally would like to see Swift go this route more often though, with more separate modules instead of the “everything and the kitchen sink” approach. There are several things in the stdlib that don’t need to be in the global scope always (like Time APIs, Concurrency APIs, Mirror, etc.) and I think that if we were to add RigidArray, it would fall into this category for me.