I'm using similar names in the RigidArray
/DynamicArray
prototypes, but my experience using them has not been great:
-
I think there is a clear distinction between appending items by consuming a container and appending items by removing the elements from a container. The distinction is particularly important for resource-constrained cases, as the latter allows us to reuse the source container's storage without any heap allocation traffic.
I've been highlighting this distinction by using a distinct label (moving
) for the second case:
mutating func append<
C: ConsumableContainer<Element> & ~Copyable & ~Escapable
>(consuming source: consuming C)
mutating func append<
C: RangeReplaceableContainer<Element> & ~Copyable & ~Escapable
>(moving source: inout C)
-
I find that using the same copying
label for both the Sequence
-based and the (eventual) Container
-based variants is problematic, as types can (and will!) conform to both protocols. To use the same label, we therefore are forced to add an overload to disambiguate the two:
mutating func append<
C: Container<Element> & ~Copyable & ~Escapable
>(copying: borrowing C)
mutating func append(copying: borrowing some Sequence<Element>)
mutating func append<
C: Container<Element> & Sequence<Element>
>(copying: borrowing C)
This needs to be done for every operation that has a generic argument like this. This is quite unwieldy to do, because we'll want the third overload to forward to one of the other two, but the most practical way to do that is to implement the actual algorithm in an underscored fourth method, and have the relevant public API forward to it. Doing that feels like fighting against Swift. It seems preferable to keep these distinct by using distinct labels.
-
A similar problem applies to the container we're appending to -- it can (and often will) conform to both the existing RangeReplaceableCollection
protocol and its equivalent in the noncopyable domain (let's call it RangeReplaceableContainer
). RangeReplaceableCollection
already comes with an append(contentsOf:)
operation that takes a Sequence
-- it seems best not to have both that and a new append(copying:)
synonym for the same, especially as we've seen in point 3, the latter causes problems elsewhere.
While using these prototypes, I keep switching back and forth between using the copying
and contentsOf
labels for the Sequence
/Collection
case. I prefer the clarity of copying
, but as the type author I deeply dislike having to deal with the extra overloads, and as a type client it causes discomfort that I cannot select which algorithm I want to use.
Provided that we do not make language-level changes to make it easier to disambiguate between these flavors, I suggest the following naming scheme:
extension OutputSpan where Element: ~Copyable {
mutating func append<
C: ConsumableContainer<Element> & ~Copyable & ~Escapable
>(consuming: consuming C)
mutating func append<
C: RangeReplaceableContainer<Element> & ~Copyable & ~Escapable
>(moving: inout C)
}
extension OutputSpan /* where Element: Copyable */ {
mutating func append<
C: Container<Element> & ~Copyable & ~Escapable
>(copying: borrowing C)
mutating func append(contentsOf: some Sequence<Element>)
}
I think the labeling we apply here generalizes well to the standard insert
and replaceSubrange
methods, and any other generic algorithm that transfers items between container types:
Sequence/Collection |
Borrowable container |
Consumable container |
Range-replaceable container |
append(contentsOf:) |
append(copying:) |
append(consuming:) |
append(moving:) |
insert(contentsOf:at:) |
insert(copying:at:) |
insert(consuming:at:) |
insert(moving:at:) |
replaceSubrange(_:with:) |
replaceSubrange(_:copying:) |
replaceSubrange(_:consuming:) |
replaceSubrange(_:moving:) |
Obviously, we aren't shipping Container
/RangeReplaceableContainer
/ConsumableContainer
protocols yet. But we will definitely have something along those lines, and OutputSpan
should not make premature decisions about its API that will make it more difficult to integrate them into the language later.
There will be (many) types that support all of these four ways of transferring elements out of them. It seems desirable to allow the calling code to select which flavor it wants to use, every time it performs an operation, and distinguishing their names is a good way to do that.
Note: There is another way to express these operations, where there is but one append
/insert
/replaceSubrange
method, using OutputSpan
as the choke point:
protocol RangeReplaceAbleContainer {
// Append items to a container by directly initializing underlying
// storage. `body` is potentially called multiple times to initialize
// successive chinks of piecewise contiguous storage. It is expected
// to fully initialize the output span it is given, every time it is
// called.
mutating func append<E: Error>(
count: Int,
by body: (OutputSpan<Element>) throws(E) -> Void
) throws(E) -> Void {...}
}
var items: some RangeReplaceableContainer<Int> = ...
// append the values 0 ..< 1000 to items
var value = 0
items.append(count: 1000) { span in
assert(value + span.count <= 1000)
for i in span.indices {
span.append(value)
value += 1
}
}
While this is extremely useful as a low-level plumbing primitive, I do not believe this would be acceptable as the primary user-facing way to append/insert/replace things in containers -- the generic operations are far more convenient.
Extra twist: dealing with the lack of slicing
Noncopyable containers are not expected to be sliceable, and that induces even more flavors for these. It is quite reasonable to expect that I'll be able to append items 5..<10 in one container onto another:
// Naming scheme is just an example
target.append(copying: 5 ..< 10, in: source)
target.insert(moving: 2 ..< 7, in: source, at: 5)
target.replaceSubrange(4 ..< 8, copying: 3 ..< 5, in: source)
Additionally, our existing RangeReplaceableCollection
operations also support inserting the contents of a container into itself. This usually triggers expensive copy-on-write copying, but it does work -- and it does not translate at all to the noncopyable universe. (The Law of Exclusivity disallows passing borrows/inout references of an entity to its own mutating methods.) So to allow us to copy/move items within the same container, we'll also need to invent new operations that are dedicated specifically to that.
target.append(copying: 4 ..< 12) // bad: can become ambiguous if Index == Element
target.insert(copying: 4 ..< 12, at: 2) // bad, for the same reason
target.insert(moving: 4 ..< 12, at: 2) // bad, for the same reason
target.copySubrange(4 ..< 12, to: 2) // okay
target.moveSubrange(4 ..< 12, to: 2) // okay
Note that existing types like Array
will certainly want to offer all these same operations, so that we can efficiently append/insert items into them by explicitly copying/moving/consuming items from arbitrary source containers. If we do not make language-level changes, then the only way to do this is to add all these variants, and have them pop up in code completion lists even for people who aren't interested in the borrowing/consuming/moving distinction.
It seems like we do need to provide (at least some of) these new variants, to make Swift a viable language for crucial new use cases that require them.
But how many variants of append
are too many? At which point will people start to get overwhelmed with choice?
Typically types already provide several overloads of these operations to implement fast paths for specific argument types; those already appear in API docs and completion lists. But we're looking at multiplying the amount of variants by 4 or more.
Is there anything the language can do to avoid forcing libraries to blow up their member namespaces like this?