SE-0390: @noncopyable structs and enums

Since values of noncopyable structs and enums have unique identities, they can also have deinit declarations, like classes, which run automatically at the end of the unique instance's lifetime.

The first thought that came to my mind after reading the above sentence from the proposal is… why give structs/enums unnatural superpowers rather than allowing classes to be move-only?

In my mind, structs/enums are like plain old values that are meant to be copied. On the other hand, classes seem well-equipped to handle the primary example in the proposal of ensuring a resource is freed. The Swift documentation even says the following:

Use classes when you need your instances to have this kind of identity. Common use cases are file handles, network connections, and shared hardware intermediaries like CBCentralManager.

Instead of giving structs/enums a deinit, couldn’t we instead allow classes to be move-only? The “move-only” part would sort of be like an optimization of the class implementation: it’s still designed to be unique and passed around but because there is ever only one active reference to the class, the class could be implemented by the compiler more efficiently (i.e. not on the heap, no reference counting).

i envision the most common use case of noncopyable will be to store something inline within a class allocation that has a lifecycle that aligns with that of the class but doesn't itself require a separate allocation. today we have to resort to unsafe constructs and remember to call destroy() in the class deinit. so restricting the noncopyable types themselves to being classes doesn't make much sense to me.

2 Likes

I think you could also argue the opposite angle, that classes in their current form have a bunch of properties that move-only values ideally shouldn't. Even if they were required to have a unique owner, classes today also currently always have fixed addresses (which a move-only value doesn't, since it can be moved to a new owner), they can be subclassed (so they aren't necessarily fixed-size and wouldn't be storable inline), and have an "isa" pointer that carries metadata about the type inline (whereas structs and enums have no inline header, and generic code passes their metadata out-of-line). To me, it seems like a natural leap to go from structs and enums today to ones that can't be copied, since that is really the only difference. Move-only classes with forced unique ownership could also make sense, but there are more decisions to make about which of those aforementioned properties they should share with existing classes.

4 Likes

i see these kinds of discussions as a motivation for making noncopyable a keyword, like final class or indirect enum instead of an attribute. because it really is creating a new kind of type, they're not really just structs or classes with special characteristics.

4 Likes

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