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

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).

I think rather that conceptualizing them as variants it would be useful to think of composable features. If seems as though you could generalize as:

protocol Copyable: NoRetroactiveConformance

Here you’re opening a bit of a Pandora’s box of Protocols defining what a type must not or cannot do rather than what they are able to do. But I don’t think that’s anywhere near as large a conceptual leap as adding a wholly new concept of ‘type trait’ to the language.

I'd like to propose another change inspired by experience this feature: as a special case, the _ = x and let _ = x "black hole" assignments should be a borrowing operation. Normally, assignment or initialization requires consuming the value on the right-hand side of the assignment in order for the left-hand variable to take ownership of the value. However, _ = x is also an idiomatic way of suppressing "unused variable" warnings. If that consumes x, then there is no other readily-available way to express a no-op use of an otherwise-unused noncopyable binding. Although it is a special case, it seems like a reasonable change in line with developers' expectations. Thanks to SE-0366, you can still write _ = consume x if you want to end x's lifetime.

Another possibility would be to say that _ = x is a complete no-op on x, and that it's even allowed after x is consumed:

let x = Noncopyable()

let y = x

_ = x

in case we can think of a circumstance where that flexibility may be needed.

5 Likes

Would it be sufficient to retcon _ = x into meaning borrow _ = x when that feature exists? (I'm presuming that the latter would indeed be a borrow that ends instantaneously.)

I'm not as concerned about the let _ = x form and, indeed, would mildly prefer that it continue to "do the same thing" with respect to ownership as let _x = x for all x.

3 Likes

yes, i was definitely confused by this.

If _ = x is used to suppress an unused variable warning, why can't it be consuming at the same time? In what case would you like _ = x to not consume x because it is unused and it needs its lifetime unchanged?

There are a couple of cases that I can think of:

  • x may not be consumable at all in the first place, or may require replacement after being consumed, if it's a borrowed or inout parameter, or eventually a borrow or inout local binding.
  • x may have a deinit with side effects that the code is trying not to disturb.
2 Likes

(Apologies if this has been discussed before; i just hopped into the thread after being tagged in a code owner review.)

I'm curious to know how this shows up/should show up in symbol graphs and documentation. Since @_moveOnly is a UserInaccessible attribute, it doesn't show up in symbol graph declarations. We also don't print the protocol conformance/inheritance line either, since we rely on the generated relationships instead for that information. Can/should we make an exception for ~Copyable, though? Barring adding an entire new field for it and figuring out how to display that in documentation, i'm not sure the best way to handle it.

1 Like