SE-0390: @noncopyable structs and enums

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 BinaryInteger β†’ SignedInteger, 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

This is why I've been careful not to describe Copyable as a protocol, because it isn't. Protocol conformances are extrinsic to the type definition and can be provided independently, retroactively, and multiple times for the same type-protocol pair. Copyable on the other hand is an intrinsic property of the type; as you noted, if the type isn't fundamentally copyable, there's no way to provide an implementation that makes it so. The closest analogy in the language today is AnyObject, which is a constraint that also describes an intrinsic property of the type (it is only a single refcounted class reference) which cannot be retroactively implemented. As such, I think it would make sense to ultimately model Copyable as a layout constraint alongside AnyObject.

I don't think the Noncopyable constraint makes any sense on its own, since unlike SignedInteger or UnsignedInteger, you don't get any additional capabilities in a generic context from requiring it. (And, strictly speaking, a pedant could implement a SignMagnitudeInteger or some other exotic representation type that is neither.) "Noncopyable" is strictly the lack of Copyable capability, and we don't generally entertain those sorts of negative constraints.

6 Likes

By this logic, Sendable shouldn’t be a protocol.

1 Like

One way that Sendable acts like a protocol is that it imposes constraints on inherited types. Arguably, as the instigator of marker protocols, it isn’t in fact a protocol, but a constraint that rides along on the protocol machinery.

In an ideal world, I'd agree with you. But Sendable is just barely a protocol, because while there is a way to trivially satisfy its requirements by being structurally composed of Sendable types, that is both not always necessary and not always sufficient to do so. A type can be composed of non-Sendable components but be Sendable by virtue of providing synchronization for those components, or conversely, it may use Sendable components to represent resources that are not sharable across threads. There is also the practical matter of migrating existing code to the concurrency model, which occasionally requires clients to retroactively promise types they don't define are safe to share across threads. As such it is useful to model as an extrinsic requirement.

If it weren't for the need for Sendable to fit within existing ABI, it would be very useful for it to have runtime discoverability, and at that point, it'd be a regular protocol. I haven't seen a good use for "marker protocols" that isn't forced by the compatibility constraints on Sendable's design.

3 Likes

As defined in the proposal, we get the deinit capability, and copyable structs and enums don't get that. So in this proposal non-copyable isn't strictly a subset of the capabilities of copyable and so a hypothetical maybe-copyable type can't have a deinit.

But if we're going to allow deinit on copyable types, wouldn't it make more sense to make it work as if Copyable is an implicit conformance unless there's a deinit? If you don't want your type to be Copyable, all you have to do is add a dummy deinit. And if you have a deinit, you can implement Copyable manually, like this:

protocol Copyable {
   func copy() -> Self // or another name/operator
}

// implicitly Copyable
struct X {
   var i: Int
}

// Not Copyable because of the deinit
struct Y { 
   var i: Int
   deinit {}
}

// but we can manually implement Copyable
struct Z: Copyable {
   var i: Int
   func copy() { Z(i: i) }
   deinit {}
}

This is similar to enums which are implicitly Hashable, but must have an explicit conformance and implementation in certain situations.

And it'd work the same way with generics:

// G is not Copyable because `var element: Element` isn't copyable
struct G<Element: ?Copyable> {
   var element: Element
}
// but we can make it Copyable when Element is Copyable:
extension G: Copyable where Element: Copyable {
   // use default implementation
}

If we add a custom deinit we should force a custom copy() implementation too:

struct G<Element: ?Copyable> {
   var element: Element
   deinit { print("deinit") }
}
extension G: Copyable where Element: Copyable {
   // can't use default Copyable implemantion because of deinit
   func copy() -> G { print("copy"); return G(element: element) }
}

All that to say if we allow copyable types to have a deinit, then we don't really need to distinguish maybe-copyable from non-copyable. And so I don't think we need a @noncopyable attribute: the presence of the deinit can prevent the type from being Copyable. And I suppose ability to strip an implied conformance with ?Copyable can make this explicit where needed (for generics and maybe for public interfaces).

3 Likes

I think "infer Copyable unless something makes the type fundamentally non-copyable" is a valid design direction. However, the presence of a manually-defined deinit (and, indirectly, a manually-defined copy) does have some knock-on effects on how the type can be used. Since the deinit requires a fully-initialized value to destroy, a type with a deinit has to always be in a fully-initialized or fully-destroyed state. If we support piecewise consumption of fields inside types in the future, types with manually written deinits would not be able to participate (except in situations where the code is allowed to forget the value so the deinit does not run on the remnants of the value). So we still might need some way to suppress inferring copyability for a type that consists of copyable fields, has no custom deinit logic, but still wants to allow for piecewise decomposition under that regime, although that might be a bit of an edge case. (Or we treat a spelled-as-empty deinit {} specially as a request for the default implicit deinit you would get normally.)

3 Likes

This isn’t strictly true; we could say, for example, that the Copyable protocol requires an init(copying: Self) method/copy constructor, and that (in the abstract model) each semantic copy calls that method. The compiler automatically generates conformances to Copyable in most cases (providing that copy constructor), but a retroactive conformance to Copyable could be done by providing that init(copying: Self) method in an extension.

I don’t actually think we should allow retroactive conformances to Copyable outside of the same file where the type is defined – I’m not sure what the use case is, so it seems a reasonable restriction –, but that doesn’t mean it’s nonsensical as a model, and why I think modelling Copyable as a protocol does actually make sense.

3 Likes

I like the rest of what you wrote, as well as what @1-877-547-7272 wrote before IRT how to codify copyable vs non copyable using a marker protocol hierarchy.

In regards to this:

I am not familiar with the "?Copyable" syntax. If it means "isn't copyable" should that be spelled out as "!Copyable", or is it supposed to mean "maybe Copyable" (in which case it makes sense)?

1 Like

Practically speaking, though, a copy operation pretty much always needs a matching destroy operation, and because the destroy operation is what runs implicitly when values of a type are destroyed, that destroy operation has to be an intrinsic property of the type. So if the type doesn't already have a suitable destroy operation to match the copy you have in mind, you can't really use your retroactive copy operation as the language-ordained copy operation since it wouldn't be balanced out by the destroy operation. That's why I think the copy operation is also intrinsic to the type. You could define a copying protocol with a copy requirement, but you'd probably also need to give it a matching destroy requirement, and at that point you'd have invoke both of them manually.

This is mentioned in the proposal under Alternatives Considered and Future Directions. It doesn't really mean it isn't copyable, only that we're not inferring copyable here (as it would implicitly have to be inferred for backward compatibility because all types are copyable today). So yes it is maybe-Copyable, the same way the type is maybe-Equatable and also maybe-Identifiable (because those weren't spelled out, whereas not spelling Copyable would make it Copyable).

I'm not too much a fan of the ?Copyable syntax because I find it unintuitive, and I fear it might become sort of viral when dealing with generic types like containers, but something like it is needed if we want generics to be able to express non-copyable types without a huge source compatibility break.

Perhaps in practice the impact could be lessened. For instance, we could start with a protocol that does not infer Copyable like this one:

protocol Resource: ?Copyable { 
   func acquire() 
   func release()
}

And then decide using this protocol in a generic declaration would not infer Copyable:

struct ResourceManager<R: Resource> {}

And maybe conforming to this protocol in a struct declaration could also not infer Copyable:

struct MyResource: Resource {} // not implicitly copyable

I'm not too sure how viable this is, but I like the concept of inferring ?Copyable β€” inferring the lack of inference for Copyable β€” whenever possible instead of having to spell it out every time.


Edit: I don't think inferring ?Copyable makes sense in the struct declaration after all: the presence of a deinit or an explicit ?Copyable is what should decide. But I still think it does in the generic constraint case.

I like the parallels you draw here, but then does that mean that Optional would have a conditional layout constraint? It's hard to wrap my mind around as there isn't an extant parallel in AnyObject as a generic type is never conditionally refcounted. I guess _Trivial would be the (not formalized) precedent here though.

1 Like

Thanks for the explanation, all makes sense now. Could we use a simpler "MaybeCopyable" instead of the not so obvious "?Copyable" (that besides being non obvious also requires some new prefix "?" operator syntax)?

Exactly, many other layout traits are in the same boatβ€”the only way a type can be "trivial", in the current sense of having a no-op destructor and being copyable and movable by memcpy, is by having storage that's made up of trivial types, and there's no way to retroactively impose that trait. In the case of generic wrappers like Optional, you're right that the layout constraint becomes conditional on the generic parameters.

2 Likes

Apart from not allowing conformances to be declared outside the defining file, what are the ways in which layout traits differ from protocols to the user? I can still see value in being able to publicly mark a type as e.g. Trivial (for ABI/source stability), add conditional conformances (within the same file), and even do protocol conformance checks (e.g. fast-pathing some operation if the type is known to be trivial). It'd also be useful to have the compiler enforce that a type can conform to a layout constraint; in the same way that an Equatable type errors if it contains non-Equatable properties (and a user implementation of == isn't provided), having the compiler error if a Trivial-conforming type contains non-Trivial properties also makes sense (as it does/would with Copyable, Hashable, Sendable, Codable, Reflectable, and any others I'm forgetting).

I guess my argument here is that they (Copyable, Trivial, Reflectable etc.) are similar enough to protocols that treating them as a restricted type of protocol makes sense from a user perspective – just "protocols that only the compiler can implement for you".

4 Likes

Yeah, being able to explicitly state "conformance" to a layout constraint as a way of making the compiler check that the type actually satisfies it would make sense. In most respects, I agree that users shouldn't have to think of them as being that different from protocols, just like AnyObject today mostly behaves like a protocol.

5 Likes

Some editorial notes:

  • Setting a subscript should be listed in the places that consume a value.

  • The example of consuming get contradicts the section on forget; it doesn’t use forget on self before returning its value. Which I guess is legal for that example, but not intended!


For those interested in linear types (deinit disallowed), I recommend Gankra’s notes on how hard it would be to add it to Rust (less hard than you’d think, but still a bit of trouble).

5 Likes