SE-0390: @noncopyable structs and enums

I don't really think so. They behave like structs and enums in all respects besides not being copyable. The treatment of noncopyable values all arises from the lack of copyability and not the other traits of the type. If you take away the ability to copy an otherwise-copyable struct or enum, either because you're in a generic context that doesn't require copyability, or because you're using the proposed borrow or inout modifiers that don't allow for copying, or we end up doing the @noImplicitCopy attribute, then those noncopyable values of copyable struct/enum types behave pretty much the same as values of noncopyable type do.

1 Like

to me, the fact that they cannot witness type requirements and they cannot conform to protocols or be used with generics is a huge difference that puts them in a completely different category mentally than “regular” structs or enums. that is just my opinion though.

2 Likes

To be clean, that's not intended to be a permanent state of affairs. Once we work on integrating noncopyable types into the generics system, then existing types will fulfill a Copyable requirement of some kind, and noncopyable types won't.

7 Likes

At first, I feared @noncopyable, the name and maybe its position as an attribute, wouldn't work well with conditional (non-)conformance in the future. But I've changed my mind after writing this. Here's my reasoning.


At first glance, if you have an array of non-copyable elements, the array itself should become @noncopyable. And it would be nice if there was not two totally different ways to express this. I suppose one way would have to be something like this:

// array is not copyable by default, and Element isn't required to be copyable
@noncopyable
struct Array<@noncopyable Element> {}

// array is copyable when Element is copyable
extension Array: Copyable where Element: Copyable {}

What the example above tells us is that Array isn't really non-copyable, despite what the attribute tells us: it's copyable on the condition that Element is also copiable. So perhaps the name should be "@ambivalentCopyable"? Although personally I'd use ~ to express ambivalence:

struct Array<Element: ~Copyable>: ~Copyable {}
extension Array: Copyable where Element: Copyable {}

But there's still a problem here. We might still need @noncopyable for cases where you need a deinit. In this case, you need to forbid conditional conformances to Copyable in an extension. For instance, Array above can't have a deinit because it is sometime Copyable, so Array can be declared ~Copyable (ambivalent to being copyable), but it can't be @noncopyable.

The result would be this:

// ~Copyable means ambivalent to being copyable
struct FileDescriptor: ~Copyable { deinit {} } // error: deinit needs @noncopyable
extension FileDescriptor: Copyable {}
@noncopyable // never copyable
struct FileDescriptor { deinit {} }
extension FileDescriptor: Copyable {} // error: FileDescriptor is @noncopyable

So the way I see it, each struct/enum can be either copyable, non-copyable, or ambivalent to being copyable. The ambivalent case would decide copyability based on generic parameters and conditional conformances, and the non-copyable case is always guarantied to be non-copiable in order to allows a deinit.

My conclusion is that the current proposal is not introducing the ambivalent case, only the non-copyable one and I assume the ambivalent case won't use the @noncopyable attribute.

These limitations are certainly not part of anyone's vision; they're just a practical way to make progress on a large and intricate design problem. We'll lift them as soon as we can work through the issues.

Tim

9 Likes

Rather than modelling @noncopyable as a modifier for the type, I still feel the clearer model is to think of it as a compiler directive to suppress the implicit Copyable conformance that is currently applied to all types.

Lots of things fall cleanly out if you have a way to suppress an implicit Copyable conformance for a scope (whether it be type scope, a section of a file, file scope, or module scope). It naturally composes with implicit Reflectable or Sendable; it’s describable with existing terminology (“this type isn’t Copyable); it doesn’t require inventing new generic syntax (a generic function that requires copyability would have an explicit or implicit T: Copyable constraint); and it easily extends to having an API- or ABI-stable module enforce that the author is explicit about which types are Copyable to avoid source or ABI breaks (for example, I’d imagine most of the standard library would eventually be compiled with implicit copyability disabled).

To allow for progressive disclosure, the assume-Copyable scopes would also implicitly add a T: Copyable constraint to any generic functions within that scope. Since everything is currently implicitly within an assume-Copyable scope, this means that code continues to compile as it does today unless the user explicitly opts out.

3 Likes

I think generics are also a good way to demonstrate how these types are still just like regular structs and enums but for the lack of copyability. I don't think anyone would argue that this is a pretty struct-like type:

struct Pair<T, U> {
  var first: T, second: U
}

But let's say we want to generalize the type to allow a pair to have one or both fields be noncopyable. The pair can only be copyable if both of its fields are, so one way to express that might be to use a conditional conformance, like:

@noncopyable
struct Pair<T, U> {
  var first: T, second: U
}

extension Pair: Copyable where T: Copyable, U: Copyable {}

The Pair type acts as just a container of its fields, whether those fields are copyable or not.

right, i was going off the assumption that this was not going to be a supported use-case, similar to the situation with custom executors in the concurrency domain. if generics are going to work with @noncopyable, my argument doesn’t apply.

1 Like

The confusing thing here is that adding a deinit to Pair makes the extension impossible. Or the extension makes adding a deinit impossible.

It's also not true to say Pair is non-copyable in this case. It is copyable under some condition. Hence why I was calling this case "ambivalent to being copyable" earlier. This ambivalence (the possibility of a conditional conformance) and the presence of a deinit are mutually incompatible.

2 Likes

I'm a little confused about this statement.

A noncopyable type can be made copyable while generally maintaining source compatibility.

Do you have an example of how this might work when making noncopyable with a deinit copyable? I assume you would be forced to remove the deinit and if so would that not be ABI breaking unlike: “However, an noncopyable type can be made copyable without breaking its ABI.“ indicates or is it somehow avoided as eluded to by "Adding or removing a deinit to a noncopyable type does not affect source for clients."?

EDIT: Ignore this ^. I was reading the wrong version of the pitch which previously stated a non-copyable type could be made copyable without breaking ABI.

--

  • What is your evaluation of the proposal?

+1 This is great addition to the language and makes it possible to write safer and more performant APIs. (I'm looking forward to writing some nice wrappers around io_object_t and playing with device drivers).

  • Is the problem being addressed significant enough to warrant a change to Swift?

Yes! move-only types are a feature sorely missed when working between both Swift and Rust.

  • Does this proposal fit well with the feel and direction of Swift?

Yes, I think the proposal makes it possible to work with non-copyable types in a way that most swift developers won't need to care about and makes it possible for library authors to provide safer APIs for lifetime management.

  • If you have used other languages or libraries with a similar feature, how do you feel that this proposal compares to those?

This feels like the Swift-y versions of Rust's Clone/Copy traits in a way that fits well in to Swift.

  • How much effort did you put into your review? A glance, a quick reading, or an in-depth study?

I've read the proposal through a couple times and played with the implementation for 2-3 hours.


Paraphrasing myself from the pitch thread; I think this should be spelled: extension MyType: @unavailable Copyable.

  1. It matches Sendable.
  2. It also shares a consistency with the : ?Copyable constant suppressor.
  3. It could be extend to work nicely with Reflectable using a consistent spelling.
  4. It leaves the door open for making implicit Copyable controllable via a language mode. The use case I see is a HAL for embedded firmware where you could want all types to opt into Copyable which would spelled with the familiar conformance syntax: extension MyType: Copyable.
3 Likes

I don't think the type level is the right level to suppress implicit copying behavior. If a type is capable of having identical copies of a value, then systemically, it should achieve that by satisfying the Copyable requirement. In contexts where a developer doesn't want code to be subject to implicit copies, it would be better to control that at the use site.

1 Like

Hmm another big difference is that we’re adding a deinit to structs/enums, no?

Even with move-only classes, I guess we would still need non-copyable structs/enums to store the objects, so I think the direction makes sense in that regard. But if we do have move-only classes, we could avoid adding a deinit to structs/enums since people who need deinit could use a class as usual.

I’m not sure why adding deinit to structs/enums seems so unnatural to me. I wonder if it’s purely a feeling of unfamiliarity or if there’s something more to it. :thinking:

What if we had a hierarchy of marker protocols like the following:

Consumable
  ↳ Copyable

All types in Swift would implicitly conform to Consumable, and copyable types would additionally conform to Copyable.

Copyable would be an implied constraint in most cases:

let x: any Any = ... // implicitly equivalent to `any Any & Copyable` aka `any Copyable`
let y: some MyProtocol = ... // implicitly equivalent to `some MyProtocol & Copyable`

However, this implied constraint would be overridable by specifying Consumable:

let x: any Consumable = ... // not copyable
let y: some MyProtocol & Consumable = ... // not copyable

This would work similarly to the way initializers work today — Swift implicitly gives some types a default initializer, but if the programmer explicitly defines an initializer within the declaration, then the default initializer will be overridden.

This would allow move-only types to be declared like this:

struct S: Consumable { ... }
enum E: Consumable { ... }

And would work well with generics:

struct Array<Element: Consumable>: Consumable { ... }
extension Array: Copyable where Element: Copyable {}

This would also work well with immovable types (i.e. a mutex) if we decide to go in that direction. We could revise the protocol hierarchy to the following:

Unconstrained (we can bikeshed the name later)
  ↳ Consumable
      ↳ Copyable

All types in Swift would implicitly conform to Unconstrained, all consumable (movable) types would conform to Consumable, and all copyable types would conform to Copyable.

Then, declaring an immovable type could be done like so:

struct Mutex: Unconstrained { ... }

I dislike this idea. Copyablility is an essential part of how a type can be used (and affects ABI stability!) and should not be left implied.

7 Likes

I very much support this proposal, I think I'm waiting for it for over 5 years now :slight_smile:.

I have two pieces of feedback:

  • Can we make sure that is is possible to write

    @available(*, unavailable)
    deinit { preconditionFailure("the impossible happened") }
    

    To be able to have @noncopyable types where the user is forced to call a consuming (and forgetful) function like close() async throws? Functions like close cannot be synchronous in all systems, many systems require deregistration of a file descriptor from systems like kqueue/epoll before being able to actually close the fd. Also close(2) can block so in some cases it may be wise to do so off the Swift Concurrency cooperative pool.

  • I'm not in love with reusing the word deinit for @noncopyable structs/enums. I'm sure we can find a word that makes it clearer what's going on and that it is not run when it has been forgetten before.

7 Likes

We already name move-only non-copy-able declaration and argument passing as consume and consuming, so @noncopyable should be named as @consumable to pair with these convention.

If design Consumable as a Protocol; the relationship between Any, Copy, Move and Pin should be <any Any> : Any : Copyable : Consumable : UnPinable, and declare a Pinable/immovable type as struct PinType : @unavailable UnPinable.

But copyable types are also consumable.

3 Likes

I think your Pair example shows that even an attribute should be named as suppressing an implicit default that might still be explicitly added back (straw man @notAutomagicallyCopyable but not @noncopyable), because the very natural inference "the type is @noncopyable therefore it is not copyable" is not valid. Maybe that makes the ?Copyable syntax appealing for declaring a type.

But there's a part in "Alternatives considered" that makes me think you have something else in mind as well:

Could you say a bit about what the Swift equivalent of Rust's "normally implicit trait not required by a generic declaration" might look like? Here's my best attempt, with no doubt many misunderstandings:

func alsoAcceptsMoveOnlyTypes<A: ?Copyable & ARelevantProtocol>(_ a: inout A) { // requires explicit convention for `a`
    // here we can't assume `a` is Copyable; what consequences does that have?
} 
func doesNotRequireSendable<A: ?Sendable & Whatever>(_ a: A) { ... } // this makes sense, but isn't this the default anyway?
func onlyAcceptsMoveOnlyTypes<A: ???>(_ a) { ... } // doesn't seem to be expressible, does this even make sense?
func returnsPerhapsMoveOnlyType() -> some ?Copyable & ARelevantProtocol { ... } // seems expressible, does it make sense? useful?

Sorry, I’m confused. Why would we define a pinable type as UnPinable? And how would it be unavailable yet still usable?

Being honest, the fact that we're only allowing user-defined deinit on noncopyable types is itself also an artificial restriction. Every type has an implicitly-generated deinit that releases refcounts, etc. For a copyable type, a nontrivial deinit almost always needs to be paired with a nontrivial copy implementation (to retain the fields that would be destroyed, etc.), and because we want to be able to optimize out copies and control object lifetimes, get systemic runtime benefits from having a global refcounting scheme, and so on, we generally want to control what those copy/deinit pairs can do. We could also eventually allow user-defined copy {} operations on copyable types, which could be paired with a deinit on those types, like in C++, but people using that feature would have to be very careful about the combined effects of those operations to ensure the language's usual optimizations don't break them.

Types that must be explicitly consumed would be yet another large extension of the model, and would mean that the "must consume" trait has to be infectious—containing types would have to inherit it, it would become another conditional generic constraint that wrapper types need to forward, you could never put such types inside globals, classes, or other shared mutable state that can never be reliably consumed out of, and so on. The fact that C++ and Rust have made it this far without having strict linear types (well, I guess in C++ you can delete the destructor, but then you can do almost nothing practical with the type if you do) makes me think they aren't really necessary. Only the owner of a value ultimately runs its deinit, so one way you might manage situations like you described is to ensure the ownership of your file descriptors is held by a context that is allowed to block, and which has enough information to properly deregister and sync them before closing them, while only allowing borrows into the worker tasks.

It does seem to me that with this design and the future possibility of ?Copyable we're squishing two things into one: a type that is definitely–absolutely–would-be-bonkers-otherwise noncopyable (e.g., FileDescriptor), versus a type that can be agnostic as to whether it's copyable and perhaps conditionally can be made copyable (e.g., Optional).

The latter "agnostic" case can indeed be thought of as a base protocol which some future Copyable protocol could refine, and as demonstrated this naturally lends itself to writing extension Optional: Copyable where Wrapped: Copyable—nice.

However, the former "definitely-noncopyable" case would be actually somewhat awkward to model in the same way: a "definitely-copyable" conformance is not a refinement really of a "definitely-noncopyable" conformance. An end user can't "improve" on FileDescriptor by making a new extension that grants it copyability. We'd have to introduce rules against retroactive conformance to Copyable and/or create some spelling to make a noncopyable type conform to Copyable in an unavailable extension in order to express "definite-noncopyability." It's doable, and there are some parallels in the design of Sendable already, but it would be nice to avoid this state of affairs if we could IMO. And more saliently for the purposes of this proposal, I think it's a hint that maybe the model isn't quite right.

I would prompt us to think about whether we can have something like the bifurcated hierarchy of BinaryIntegerSignedInteger, UnsignedInteger here. In other words, consider:

            ┌───────────────────┐
            │ UnknownIfCopyable │ (strawman name)
            └───────────────────┘
   ╷┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈╵┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈╷
┌────────────────────┐ ┌────────────────────┐
│  Copyable (≈ Any)  │ │    Noncopyable     │
└────────────────────┘ └────────────────────┘

FileDescriptor could conform directly to Noncopyable; this would be semantically exclusive with Copyable in the same way that SignedInteger is exclusive with UnsignedInteger and this exclusivity could (maybe should) be enforced by the compiler.

All current types would implicitly conform to Copyable. A type like Optional could (as part of a future generalization of this feature) disable this implicit inference by conforming to UnknownIfCopyable, and then it could state an explicit conditional conformance.

4 Likes