[Pitch] Noncopyable Generics

Hi folks,

The noncopyable types introduced in SE-0390: Noncopyable structs and enums come with the heavy limitation that such values not be substituted for a generic type parameter, erased to an existential, or even conform to a protocol.

I've put together a pitch that lifts those restrictions by extending Swift's type system with a foundational syntax and semantics for noncopyable generics. I'd greatly appreciate your feedback and discussion about this!

You can find the full-text of the pitch here

If you'd like to give the work-in-progress implementation a spin, click to expand.

There are pieces of this proposal implemented in Swift main under the -enable-experimental-feature NoncopyableGenerics flag, though there are a few known issues generating executables with this flag, so I suggest sticking with the front-end option -typecheck for now. Also, it's a known bug that stdlib types are being treated as if they are not Copyable when using this flag.

33 Likes

Minor thing: is this an unintended double negative? Or am I misreading?

This proposal completes the picture by defining the semantics of ~Copyable as the inverse of a constraint that cancels out the default requirements for Copyable conformance.

My brain parsed this as “the inverse of [a constraint that cancels out [the default requirements]],” i.e. there could be some other constraint that cancels out the default requirements for Copyable, and ~Copyable is the inverse of that cancellation.

I assume the intent is something more like this?

This proposal completes the picture by defining the semantics of ~Copyable as an inverse constraint that cancels out the default requirements for Copyable conformance.

Reference types like classes and actors can always conform to Copyable , because a reference to an object can always be copied, regardless of what is contained in the object.

It might be just tangential, but I've never liked this way of looking at things. Most reference types are not copyable in the real sense - a la NSCopyable. Swift doesn't even have a standard protocol for copyable types like this (possibly a future direction, as a Clonable protocol?).

Pointers are copyable (or "references" if you prefer that language). The things they point to usually aren't.

5 Likes

Applying an inverse T: ~Copyable does not prevent a Copyable type from being substituted for T , i.e., it does not narrow the kinds of types permitted, like a constraint would.

Semantically it's a bit awkward to describe a generic constraint as being of a non-copyable type when actually that means either copyable or non-copyable.

Practically what you're proposing seems functional, and I get what you're trying to say about ~ meaning "opt out of an otherwise implicit conformance" / "not necessarily conforming to" as opposed to "not conforming to", but I fear it'll be a frequent source of fundamental confusion. I find it hard to even parse the proposal's details & examples because I instinctively just keep reading things as "blah blah T not Copyable…" and under that interpretation a lot of what's proposed doesn't make any sense.

It's possible that eventually we'll get to a point where Copyable is no longer implicitly applied (at least for generic parameter and return types), at which point ~Copyable looses all purpose (under this proposal). Which isn't necessarily a bad thing - maybe that's just a natural obsolescence - but worth pondering.

1 Like

pointers are copyable you copy them by incrementing the pointer and you consume them by decrementing the pointer.

Think of it this way: The type SomeClass is not the type of an instance; it is the type of a reference to the instance. And that reference is copyable.

One could imagine a version of Swift where SomeClass.Instance was a struct for the noncopyable (and, in fact, nonmovable) instance itself, but the Swift we currently have doesn’t happen to expose that concept directly.

1 Like

It helps to read the tilde as “[only] maybe”, as in “T is maybe-Copyable”. If it was supposed to be read as “not”, it would have been written “!” (this was discussed in the previous proposal).

5 Likes

Exciting to see the next step on this path!

I've understood as a Swift user that where T : Fooable means "where T is a concrete type that must satisfy the requirements and provide the guarantees of protocol Fooable". Or for short, "T must be Fooable".

I'm also reading that where T : ~Copyable means "where T is a concrete type that need not satisfy the requirements nor provide the guarantees of protocol Copyable". Or for short, "T needn't be Copyable". I think this is what's being proposed.

But if I first encountered this syntax at a point of use, I would have assumed that where T : ~Copyable means "where T is a concrete type that must satisfy the requirements and provide the guarantees of protocol ~Copyable", or "T must be ~Copyable". This feels like a potential pitfall for Swift users acclimating to the proposed syntax.

When I see the : operator, unless square brackets give me the hint that there are dictionary pairs are afoot, I read an affirmative requirement like "is a" or "must be". Has that been the intent? Is there a risk here of undermining that familiarity?

I think the idea is to negate "must be"—the verb, not the object—so what if the negating ~ was attached to the : operator, not the protocol operand?

Here's what that could look like: where T ~: Copyable might mean "where T is a concrete type that need not satisfy the requirements nor provide the guarantees of protocol Copyable", or "T needn't be Copyable".

One reason ~: might not make sense is if ~Copyable isn't just a representation of negating Copyable's requirements and guarantees, but also introduces its own. In that case T : ~Copyable does something just like any other T : Foo, but it also negates Copyable's requirements.

2 Likes

Just as a point of clarification, the syntax T: ~Copyable is not part of this pitch—it is already accepted syntax from SE-0390 after multiple rounds of review.

6 Likes

As someone who was also compelled by the “we should invert the verb not the noun” line of thinking what convinced me of the current spelling was @Joe_Groff’s point that even if we went down the ~: road we would likely find ourselves reinventing the ~Copyable spelling anyway as soon as we wanted to write an existential not-necessarily-copyable type, e.g. any Fooable & ~Copyable.

14 Likes

I missed that prior discussion, I think.

~ means literally the inverse, mathematically, which means if something was copyable then ~copyable means not copyable. For our purposes here it's a synonym with ! because there's no two's complement of a concept.

If it were meant to mean "maybe" then it should have used ?, following the established pattern for that symbol in Swift.

I know that some folks use "~" to means "maybe" or just more vaguely express uncertainty (e.g. "~=" in Swift, and also in general written prose) and I've probably even done that myself a few times. But it's definitely not what it's defined to mean from a CS or M academic background.

Obviously I'm not arguing anything should change at this point - that ship has sailed - I'm just pointing out why this is going against intuition [for me] and I suspect will confuse a lot of people [indefinitely]. Especially as the significance and frequency of the ~ protocol modifier increases.

1 Like

How about “known to be” then? “X is [known to be] Comparable”, “X is not [known to be] Copyable”. Or “guaranteed to be”.

~ is well-known in programming to mean “bitwise NOT” (C), but also fuzzy comparison (Perl), home directory (shell scripts), truncation (MS-DOS file names), and even a similar kind of inverse constraint (Rust). In math, negation is represented by ¬.

I think you’re correct that this takes some learning, but it was discussed quite a bit already, and nothing better was discovered. (And it shipped already in Swift 5.9.) For people familiar with programming learning about non-copyable types for the first time, I don’t think this is going to be the hardest part.

11 Likes

All of these points were discussed extensively in the prior review, both in the review thread and by the language study group. The set of plausibly-justifiable characters is basically ~ , - , / , ? , ^ and !, none of which is perfect. All have semi-reasonable arguments for and against. None of those arguments were judged to be especially compelling by the LSG. There was a slight preference for ~, so we went with that, knowing that whatever we pick, people will get used to it pretty quickly.

Anyway, let's try to stay focused on the pitch at hand.

15 Likes

I'm not sure I really like all of the implicit behaviours here. Taking the example from the proposal:

struct Pair<Elm: ~Copyable> { ... }

struct RequiresCopyable<T> {}

typealias Err = RequiresCopyable<Pair<FileDescriptor>>
// error: type 'Pair<FileDescriptor>' does not conform to protocol 'Copyable'

Even if a developer understands that structs and generic types and such are assumed to be Copyable, I don't think it's going to be immediately obvious why Pair isn't Copyable here. Especially when things seem to work when they use a class instead of a struct.

I think this really needs diagnostics which explain the inferred conditional-copyability.

But even with that, personally I don't think it's that bad to write those constraints explicitly. I wonder if it is truly worth the added complexity of yet another magic inference rule.

- struct Pair<Elm: ~Copyable> { ... }
+ struct Pair<Elm: ~Copyable>: ~Copyable { ... }

+ extension Pair: Copyable where Elm: Copyable {}
8 Likes

I can definitely see the impetus for trying to do something to reduce boilerplate here, but ultimately I think I agree with @Karl.

First, I had to remind myself if we had already built some sort of inference rule into SE-0390 that comes into play: One plausible design choice would have been that a struct is inferred not to be copyable if it has a stored member that's noncopyable, which would naturally extend to a type like Pair<T> here. But in SE-0390 we actually went the other way: we said that the compiler would error if a struct with a stored member that's noncopyable didn't explicitly state the "inverse conformance."

This leaves us with the option chosen here, to allow for Pair<T> to elide the explicitly stated "inverse conformance," not on the basis of stored members but on the basis of the T: ~Copyable "inverse constraint." For the end user reading Pair<T>, though, inference-from-constraints and inference-from-members have an indistinguishable end result.

Since we only allow one sort of inference but not the other, I expect this would lead to surprises for users if two structs, generic Foo<T> and nongeneric Bar, have essentially identical declarations and constraints, but one "just works" and the other doesn't. Pair that with different rules for classes, and I think this rightly gets quite confusing.

While it is repetitive, I would agree with @Karl that the resultant code isn't hard to read and not overly burdensome to write if we say no to inference-from-constraints just as we do in SE-0390 to inference-from-members. If that's found to be too repetitive down the line, it seems this would be an ideal use case for a macro, something like:

@ConditionallyCopyable
struct Pair<T: ~Copyable> { ... }

// expands to:
struct Pair<T: ~Copyable>: ~Copyable { ... }
extension Pair: Copyable where T: Copyable { }
6 Likes

I haven't read the full proposal yet but I figured that would be required. After all, just because the generic is ~Copyable doesn't mean the type itself is:

struct PointerHolder<T: ~Copyable>: Copyable {
  var pointer: UnsafePointer<T>
}

Yeah that's a typo thanks! Your assumed intent is correct and I've pushed a batch of typo fixes.

Right, this is why noncopyable values can be stored in a class/actor. Because copying a reference type copies a reference, never the values it points to.

Personally, I read T: P as "T is constrained to be P". For T : ~Copyable I read it as "T is constrained to be without Copyable". Thus, I think of ~Copyable as "without copyable". That terminology implies that it's assuming that T was implicitly defaulting to Copyable, which is the right way to think about what ~Copyable operates on: default requirements.

That reading also makes sense in situations where the Copyable is actually required of T by another requirement (implied or explicit):

func f<T>(_ t: T) where T: ~Copyable, T: Copyable {}

The above would be diagnosed as an error, because you cannot be without Copyable and also require Copyable. Same goes for a composition like Copyable & ~Copyable being an error. I've pushed an example along these lines to the pitch.

2 Likes

I also am unconvinced on the proposed inference rule, but I’ll note that the difference between inference based on generic parameters and inference based on stored properties is that generic parameters are always public and changing them is source-breaking, while stored properties do not have either of those restrictions.

11 Likes

That's a good distinction to point out, yes :slight_smile:


Follow-on questions/issues related to inference:

The proposal states that protocols which refine ~Copyable protocols must explicitly state non-copyability:

protocol P: ~Copyable { }
protocol Q: P { }
// `Q` is implicitly `Copyable`, which is allowed since it is "more refined" than `~Copyable`
protocol R: P, ~Copyable { }

While I understand how one arrives at this rationale, it's opposite the usual rules and therefore will inevitably catch some folks by surprise. The lack of any written artifact of the inferred copyability compounds the problem by making it harder to communicate the correct interpretation.

Also, if I understand correctly, given protocol P: ~Copyable, then for a conforming type:

struct S: P { }

...S is intended by this pitch to be Copyable using the same rationale, but it is not demonstrated explicitly in the pitch examples.


Supposing that we take the pitch as stated such that S is implicitly Copyable just as Q is, what of the following?

struct T<U: P> { } // `U` is constrained to `P: ~Copyable`

By the logic of constraints, U must not be implicitly Copyable; rather, U: P implies U: ~Copyable even though Q: P above implies Q: P, Copyable. Tricky, but learnable.

Then, by the inference rules pitched here, T: ~Copyable because U: ~Copyable (even if nowhere is there a tilde in the declaration).

However, if instead I declare struct T2<U: Q>, then since Q is implicitly unconditionally Copyable, T2 is also implicitly unconditionally Copyable. Or to put it together:

protocol P: ~Copyable { }
protocol Q: P { }   //  Copyable
struct T<U: P> { }  // ~Copyable
struct T2<U: Q> { } //  Copyable

For three declarations that each use what's superficially the same :, my feeling is that there are too many interacting rules of inference at play here.

Ah, and I forgot—we have primary associated type notation for protocols now: since those aren't generics per se, things look similar but then diverge...

struct S<T> where T: ~Copyable { }
// `S` is implicitly `~Copyable`, per pitch

protocol PP<T> where T: ~Copyable { associatedtype T }
// ...equivalent to:
protocol PP<T> { associatedtype T: ~Copyable }
// `PP` is implicitly `Copyable`, per pitch

I'm wary of this amendment and of this interpretation of SE-0390.

A protocol which is Copyable can refine a protocol which is ~Copyable and a generic type that is ~Copyable can be conditionally Copyable. Thus the syntax ~Copyable very much does not mean "without Copyable" in those contexts. Rather, there is a relationship whereby Copyable refines ~Copyable—if we wanted to stick with your terminology, we could perhaps say that ~Copyable is read "without Copyable yet."

We allow—even require, in the case of conditional conformances—writing out multiple refining requirements such as T: Sequence, T: Collection; and I see no reason to ban this particular one here.


Overall, I have no doubt we can keep patching special rules upon special rules to try to make each individual example work, but it doesn't feel like the best direction for getting to a workable whole.

While I am reasonably happy with the ~Copyable spelling we ended up at in SE-0390, I would urge us not to regard this as some special operator that needs exegesis and new semantics. I'd be curious how far we get if instead we replace ~Copyable in one's mind's eye with a run-of-the-mill spelling such as PossiblyNoncopyable and consider what falls out from existing rules when we have protocol Copyable: PossiblyNoncopyable { }; typealias Any = any Copyable, then add the minimum inference rules we need so that existing types written for Swift 5 are inferred to conform to Copyable.

7 Likes

Another thing to keep in mind is that Copyable is probably not going to be the only one of its kind, as far as formerly-universal capabilities being turned into explicit requirements. For instance, we've been recently exploring the possibility of introducing first-class nonescapable types, one possible design for which would be to introduce Escapable as another provided-by-default capability that can be suppressed with ~Escapable, by analogy to ~Copyable. So the design we choose for Copyable here will likely provide precedent for other revokable capabilities in the future.

28 Likes