[Pitch] Noncopyable (or "move-only") structs and enums

I read through the pitch but its not clear to me if there's any way to mark a type as not being deinit-able. Example use: say I had type like AsyncInterruptTrigger which releases a suspension point elsewhere. I the library author want to enforce that the owner of one of these triggers either calls fire() or cancel() in order to consume the instance. How would you do that with this pitch?

EDIT: I realized this was discussed earlier as "linear vs affine types". I still think this is something worth mentioning in the pitch under alternatives considered or future directions or both.


I know this isn't being pitched, but do you see a future direction where a language mode could be applied to entire module to opt into this behavior? I see use in environments such as firmware HAL libraries where you might want to flip the defaults?


English language bikeshedding

Some dictionaries specify that "copiable" is the standard spelling for "able to copy", although the Oxford English Dictionary and Merriam-Webster both also list "copyable" as an accepted alternative. We prefer the more regular "copyable" spelling.

The negation could just as well be spelled @uncopyable instead of @noncopyable. Swift has precedent for favoring non- in modifiers, including @nonescaping parameters and and nonisolated actor members, so we choose to follow that precedent.

Others have mentioned the use of an @attribute feels a bit off. I think this concept is more simple to explain syntactically to new users by leveraging existing precedent set with Sendable:

struct FileDescriptor { }

@available(*, unavailable)
extension FileDescriptor: Copyable { }

I think this has value if a language mode to switch the default is in the cards as making a type Copyable can also leverage the same familiar syntax.

// My module with some language mode flag like --disable-implicit-copyable
struct Foo { }

extension Foo: Copyable { }
1 Like

Another possibility to reduce annotation noise might be for us to say that types implicitly lose their copyability when they do something that forces them to be noncopyable, like declaring a deinit or containing a noncopyable field. We already do this with Sendable for non-public types, which are implicitly Sendable if their components all are.

1 Like

We're also discussing a @noImplicitCopy attribute that can be applied to normally-copyable things to get fully manual copying behavior. Whether a type is copyable or not is a fundamental part of its representation, so it wouldn't work well to have types sometimes be copyable or not, so I think that sort of annotation, to tell the language not to implicitly take advantage of copying even if a copy operation is available, is a better way to go for the sort of situation you describe. The pitch that's up only discusses @noImplicitCopy as a variable modifier, but I think we could support it for scopes as well, and one of those scopes could be "the entire module". However, one thing to work out with these scoped noImplicitCopy modifiers is whether there should be exceptions to allow some types to always be copyable. You probably never really care if an Int gets implicitly copied, for instance, and it would be annoying to have to think about copying at that level.

1 Like

I think this is really the right way to go. I'd expect it to be the common case that we care about copyability not just for a specific type but for regions of code or entire modules. I'd argue it's less ergonomic to have to express negative constraints repeatedly for individual arguments or types than to flip the defaults for regions of code (files, modules, or possibly sections of files using something like #pragma); I think it'll be rare to want to think explicitly about copyability for one type or parameter but not for all within a scope.

I think it would make most sense to treat implicit conformance as a module-level attribute that's turned on by default, and that if you passed e.g. --disable-implicit-copyability it would flip the default for that module. You could then re-enable it for sections of code that you haven't migrated or don't intend to migrate using something like @beginScope(ImplicitlyCopyable).

Under this sort of model, you don't need to consider negative constraints; you would write the code in a scope where the Copyable constraint is assumed not to exist.

In this design, all existing modules would be treated such that all types automatically conform to Copyable, all generic constraints have an implicit Copyable constraint, and all protocols descend from Copyable. In a scope that opted out of implicit copyability, those extra requirements would have to be spelled out explicitly, and all types and variables within that scope would need to be explicitly copied (assuming they conform to Copyable) or moved.

With regards to types that we still want to allow implicit copying on: a module built without implicit copyability enabled could introduce its own conformances to a marker protocol ImplicitlyCopyable for Copyable types (e.g. extension Int: ImplicitlyCopyable {} if Int was not marked as ImplicitlyCopyable in its defining module). It could also conform any types it vends to ImplicitlyCopyable, which would also imply a Copyable conformance. Any structs or enums that conform to Copyable would not be allowed to have a deinit.

As a side note: we could apply this same solution to Opt-in Reflection Metadata, since again that's a place where we're wanting to change the default for regions of code – in that case implicit conformance to Reflectable rather than Copyable.

I don’t think the problem here is the negative copyable constraint ?: Copyable, but rather implicit requirements. I think a better name for your attribute is @implicitConstraint(MyProtocol), which —as you say— could generalize to other feature like reflection.

This syntax generalizes a lot better. Swift already synthesizes some implicit conformances (e.g. Sendable), so a general syntax would be a great way to opt out. The syntax would also naturally extend to opting out of the aforementioned implicit constraints. But more importantly, Copyable should be a protocol. IIRC, the compiler generates witnesses for copyable types that describe how they should be copied. Even if Copyable’s requirement is an underscore-prefixed __copy() method, implementing and explaining copies in Swift would be more straightforward. So it only makes sense that a move-only type would shed its implicit Copyable conformance, like any other conformance (?: Sendable), with a ?: Copyable syntax.

I think the key distinction to draw is that I’m suggesting @noImplicitCopy be automatically applied to all code that isn’t in an ImplicitlyCopyable scope – or, to put it another way, that code that isn’t in an ImplicitlyCopyable scope doesn’t automatically synthesise conformances to ImplicitlyCopyable for Copyable types that are used within said scope (wherever they may be defined). That’s in addition to suppressing automatic conformance of Copyable for all types defined outside of an ImplicitlyCopyable scope.

Rephrasing without negative constraints: code within an ImplicityCopyable scope automatically generates conformances to ImplicitlyCopyable for all Copyable types regardless of where they’re defined, and additionally synthesises conformances to Copyable for all types defined within that scope. All current Swift code is assumed to be implicitly within an ImplicityCopyable scope.

1 Like

Yes, I also think this is very important for a large number of use cases for move-only types. I think I would want to make it possible to mark deinit as public, private, fileprivate, or internal, because the type itself might just be a move-only token used as an API currency type.

This is very nicely subsetted out from the full feature set. Obviously I look forward to generics-compatible non-copyable types, but I agree that this has use cases on its own, and it is definitely easier to review these proposals in chunks rather than all at once, even as we treat them as part of a whole. Kudos!

People already seem to be discussing a number of things I'm interested in, especially "should the attribute affect the type's generic parameters" because that does affect future proposals. There's only one thing that's jumped out at me that no one's mentioned:

For a local var or let binding, or consume function parameter, that is not itself consumed, deinit runs after the last non-consuming use.

What does "after" mean here? The example shows a non-consuming use in a function call, followed by deinitialization on the next line. What if there are nested function calls? Thus far, Swift does not share C++'s concept of a "full-expression", so would it happen between the inner and outer calls, like inout ending/cleanup? If the function is inlined, could the deinit happen before it's even complete? And if I want my binding to live longer, I can certainly use an explicit consume x, but the cost of forgetting that could result in a bug.

Between the discussion about class instances being released unexpectedly early (which I'm having trouble finding at the moment), and the precedent set by Rust, I would appreciate more discussion on why the default behavior isn't to deinit at end of scope, with an early consume allowing for more control when needed.

EDIT: I can think of one reason why this rule isn’t sufficient: directly passing an owned return value to a borrow parameter, or discarding one. It would make sense to me if those behave like inout while named bindings behave like defer, just as struct members and enum payloads have their own ordering.

5 Likes

Yes please. A major reason to adopt move semantics is to end reliance on optimizer magic. It seems counterproductive for the default mode to defer to the optimizer’s lifetime analysis.

2 Likes

Sorry for not being clear. The intent is to specify that the value is destroyed immediately after its last borrowing use ends. So that's stricter than end of scope, but should still be a well-defined location, not subject to optimizer whims, since borrows of noncopyable values begin and end at well-defined places. Looking toward values with lifetime dependencies, which may be lifetime-bound to borrows or directly contain borrows of other values, I expect that shrinkwrapping the lifetimes would get us closer to Rust's "non-lexical lifetimes" model, so code doesn't need to manually shorten lifetimes to avoid interfering borrows when values linger. I thought that was how Rust worked in general—is there a different rule for Drop types?

On that note, another area of design here has to do with library evolution and deinits—do we want to allow for public types to add deinits without affecting API or ABI? Lifetime aside, the presence of a deinit also puts some restrictions on how code outside of the type can consume it—there needs to be a whole value for the deinit to consume, so you can't partially destructure a value with a deinit by consuming some of a struct's fields or doing a consuming switch on an enum. Library evolution would also be a wrinkle in allowing for different lifetime semantics for types with or without deinits.

1 Like

Yeah, non-lexical lifetimes only apply to references; other types still use the classic “end of scope” model. See 2094-nll - The Rust RFC Book

3 Likes

Just a naming thing: I find myself thinking about what @John_McCall said about naming conventions, and my own vague intuition about there being an lvalue/rvalue-like distinction here that naming should respect.

Looking at the code examples in context in this proposal, my gut feeling is that the keyword should be a gerund (consuming / borrowing) in declarations:

  func write(_ data: [UInt8], to file: borrowing FileDescriptor) {
                                       ^^^^^^^^^

  func close(file: consuming FileDescriptor) {
                   ^^^^^^^^^

That reads better to my eye. John was skeptical of the gerund, but darn it, in context that just flows off the mental tongue, as it were. The write function writes to a file by borrowing a FileDescriptor. It’s right there in the code.

I do also see the case for a past participle:

  func write(_ data: [UInt8], to file: borrowed FileDescriptor) {
                                       ^^^^^^^^

The colon usually reads as “which is a”, and this naming fits: “Write data, which is a UInt8 array, to file, which is a borrowed FileDescriptor.” Both those options read better to me than the imperative verb borrow.

To be clear, the keyword should still be an imperative verb (consume / borrow) when applied to an expression that supplies a value:

  munge(borrow thinger)
        ^^^^^^

  funge(consume thinger)
        ^^^^^^^

Trying to articulate my intuition here: one describes what will happen elsewhere whenever the thing is used; the other describes what does happen right there in the usage site. That's post hoc explanation, to be clear; I'm just reacting to the fluency of the code itself.

5 Likes

@Paul_Cantrell Did you mean to post this over in the other review thread?

Perhaps? It's the code examples from this proposal I was referring to, though you're right that it's more on-topic for the other proposal. Feel free to move it (or let me know if I should).

I just think perhaps they'd like to hear about your opinions over there in that thread :)

munge(lend thinger) in that case, or?! :-)

1 Like

I had a similar thought. lend / loan seemed like a bridge too far at first blush…but you do have a point!

1 Like

You could argue for lend(ing)/borrow(ing) too…

That set of alternatives was already covered during the review for parameter ownership modifiers, which is also still ongoing here. Thoughts on naming the modifiers might be better discussed in the active review thread; we will use whatever modifiers get selected there in the noncopyable types proposal when it is ready for review.

4 Likes

Thanks Joe, apologies for the noise - I must say it is very hard to keep up with all threads and the preceding discussions in general, utmost respect for those of you who manage.

3 Likes