[Second review] SE-0390: Noncopyable structs and enums

There's been a bunch of great discussion about how to spell suppressing the Copyable constraint. Thanks everyone for that.

However, there has not been much discussion about the semantics of discard self (the section titled "suppressing deinit in a consuming method" in the proposal). I would like to encourage everyone to give that section a careful reading and offer any feedback you might have in the next week.

6 Likes

It’s kind of a bummer that if a class has a mutable stored property of move-only type, it will still incur dynamic exclusivity checking. It’s unfortunate that a client must either accept dynamic exclusivity checking of each stored instance property (when using a class) or static exclusivity checking of the entire aggregate (when using a pointer-to-struct). Developers might want to author a class that does its own manual synchronization to allow simultaneous access to some properties that are internally mutable. Is there a path forward for such a datatype?

Related: what restrictions apply to move-only types stored in actors?

Sure. At the class level, we’d need to know that the property can never be used in a way that requires exclusive access (like a let, although I wouldn’t suggest that as the design), and then there’s no point in doing dynamic exclusivity checks. And then your type has non-exclusive operations that use the mutex to dynamically get exclusive access to some underlying data. Ideally we’d infer the first from the second.

On the other hand, something like that could be composed out of property wrappers and noncopyable types that represent mutable-while-shared types. Atomic would ultimately be one such type, in that an "immutable" Atomic value can still be updated with atomic operations. For many cases, a non-Sendable wrapper type that behaves like C++ mutable or Rust's Cell, allowing for mutations in an otherwise shared-access context, might be appropriate too, and with property wrappers you could hide the wrapper behind a class's interface. Since property wrappers also allow access to the enclosing instance of a property, it seems like a wrapper could also implement alternative exclusivity checking that way if it wanted to, using a flag or bitfield in the instance itself instead of the global exclusivity table we use by default.

Storing a noncopyable type in an actor instance ought to behave the same as storing it in a class. The value that gets constructed into the instance would be owned by that instance for its lifetime, and wouldn't be consumable outside of deinit, if any.

I don't think it can be purely composed, because we support primitive operations on every type that normally require exclusive access — assignment, inout, and so on. We need to understand either that those operations (and all other operations) don't require external exclusivity for a particular type or that the operations that require external exclusivity are not possible on a particular value. let does the latter, but let is also tied to actual immutability in the current language model, and property wrappers can't currently introduce storage that's formally a let anyway.

let being tied to immutability might be an issue, but otherwise it seems like it should compose if the type has a member with a nonmutating modify, which can be used on a let or shared borrow. The implementation can then assert or assume that it is safe to exclusively access the value at this time, by taking a lock, checking a flag, or whatever, and then provide exclusive access to the wrapped value inside. Alternatively, something like an atomic type can provide a limited API that doesn't offer fully general inout or assignment, but which exposes load, store, and RMW operations on an "immutable" value of the type. Being able to represent such self-synchronizing types as lets would be nice for actors, since then they could naturally be exposed as nonisolated let properties that can be accessed without going through the actor's usual synchronization.

1 Like

I don't see how that achieves the goal of allowing us to suppress dynamic exclusivity checks. I can see how those checks are likely to be holistically unnecessary and redundant, but I don't see how it communicates that information to the compiler well enough for it to actually stop doing the checks.

You're right that a missing link here is the ability to have a property wrapper create let storage. But if that were possible, then I don't we'd need exclusivity checks whether the type is truly immutable or self-synchronizing, since being a let should prevent anyone from asserting exclusive access to the storage.

1 Like

I appreciate the discussion here. I haven’t grokked it yet, so forgive me for asking a potentially naïve question: would the strategy being mooted apply only to properties of move-only types, or is it an orthogonal discussion?

Mutable-while-shared types in practice probaly only make sense to be move-only types. Being copyable in Swift's model has to mean that any copy is just as good as and interchangeable for any other copy, whereas a shared mutable entity, like an atomic or lock-guarded value, pretty much has to be unique to make sense.

1 Like

Sorry to continue this topic, but has there been consideration for how to spell non-copyable, non-nominal types? For example, Swift models C arrays as homogeneous tuples. How could I denote such a tuple type as non-copyable? What about non-copyable closures—are those the same thing as non-escaping closures?

I think an insightful opinion will require playing with the feature, trying to implement a type that suppresses the default deinit. I definitely want to play with it myself.

To extend on this, how can we capture and consume non-copyable values in closures?

Closures today can be executed zero or more times and there is no way of spelling that they are called exactly once. This sounds like it would not be possible to capture and consume a non-copyable type. Lots of APIs actually guarantee that they are executed exactly once, e.g. most of the with* style methods, Result {} and we also have escaping ones like Task {} and EventLoopFuture.whenComplete from SwiftNIO.

non-copyable consuming closures could guarantee that they are not executed more than once but I think we still need another spelling that requires that they are called exactly once.

Edit: non-copyable consuming closures might actually be enough to allow them to capture and consume non-copyable values. They would guarantee that the closure is executed at most once and could therefore consume non-copyable types. If the closure is not called and eventually deinitlized it would then need to deinitlized all captured non-copyable types as well which is already the case today for captured copyable values anyway.

Exactly once closures might still be interesting in combination with @available(*, unavailable) deinit {}:

6 Likes

I'm kind of struggling to find a use case for this as stated, but I would define a struct that wraps the C array and make that noncopyable.

I don't think we want to make more language decisions on top of the way fixed arrays are currently imported from C; nobody likes it (that I know of) and it desperately needs to be replaced. But a tuple would be noncopyable if any of its elements are noncopyable, once noncopyable tuples are supported, and that shouldn't need any explicit annotation. We don't have a way to directly import a C type as a noncopyable type; like Steve said, you would probably want to wrap the C type in a Swift type already to give it an API with the right borrowing/consuming behavior, a deinit if appropriate, and other functionality that integrates with the language.

It is implemented in nightly snapshot toolchains, if you want to try it out (though the declaration syntax is still @_moveOnly struct, and the disable-deinit statement is still _forget self pending a final syntax decision).

A run-once closure would be a reasonable future direction, but we don't propose that here. So there is no way to statically consume a value inside a closure yet. Nonescaping closures borrow their captures, either immutably or mutably as needed, for the duration of the call the closure is passed to, and escaping closures impose an indefinite borrow on the capture. You can still dynamically consume a captured value by wrapping it in an Optional-like enum (or eventually Optional<T> itself, once we support that), and giving the enum a mutating method that returns the value inside while resetting itself to nil.

3 Likes

One more short-term change we'd like to make to SE-0390 is to [remove the ability to declare deinit on enums] for the time being. As currently implemented, switch is a consuming operation on the value being switched, so an enum with a deinit would be of limited use, since taking apart a value with a deinit is not generally allowed without going through the deinit. When the proposal was first written, we were still hoping to also get borrow/inout bindings into the language at around the same time, which would have included the ability to switch on a value without consuming it. We can reintroduce the ability for enums to have deinits once that language functionality is in place.

10 Likes

Thinking about it, let may already not be totally tied to immutability, thanks to weak references. Even though we make you write one as a var property within a struct, you can put that struct in a let property of an outer struct:

struct Foo {
  var x: weak AnyObject?
}

struct Bar {
  let foo: Foo
}

let x = Bar(foo: Foo(x: someObject))

and while that's always been gross from a "let means immutable" perspective, it's fine from the broader "let means shared-borrows-only" perspective, since the zeroing of weak references is done in a way that's safe in the face of multiple readers. From that perspective, we could also allow for let weak properties, which would be immutable aside from automatic zeroing.

1 Like

I agree that conceptually we could use let for “cannot be used with operations that require true exclusivity”. But I think it would be really weird to have a language rule that leads to e.g. needing to declare mutable atomic properties with let in order to suppress exclusivity checking; that just seems like a whole waterfall of “I shouldn’t have to care about this” to be forcing on users.

2 Likes

Oh I agree with that part too—in fact declaring an atomic value as a var (in the sense of could-be-exclusively-accessed) in that model would probably always be a mistake. It seems like we could have a way to tell people working concretely with atomic or similar types that they should always be declared as lets, or make it so that a var actually behaves as a let, though the latter option gets weird with generic abstraction.

2 Likes

I agree with that, but that doesn't mean that the conceptual entity of Protocol could not be improved by introducing a subset variant that has different rules applied (e.g. marker Protocol) and that might ultimately be the best way to handle thinks like Copyable (and Sendable).