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

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

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?

Whether a type supports copying or not is something important to surface in its documentation. But I don't think it makes sense to add "Copyable" under a type's conformances list, since that's the vast majority of all existing types in Swift. It's also the default for a type, so swiftinterface files will not contain conformances to Copyable. This design also ensures backwards compatibility with older compilers that don't know about Copyable. Instead, the interfaces only state if a type suppresses it via ~Copyable. So as a starting point, I think documentation should also list ~Copyable under its "conforms to" header or similar.

3 Likes

As a first pass, I think this would be fine, but in the long term it would be great if the documentation actually could spell it out with a separate section titled something like “Suppresses implicit conformance to.”

6 Likes

we should model this as a characteristic of a symbol, because it is intrinsic.

{
    "kind": {
        "identifier": "swift.enum",
        "displayName": "E"
    },
    "nonconformances": ["Copyable"],
    ...
}

it should not be a symbol relationship (like conformsTo), this is a needless complication with no benefit.

surfacing this information in documentation frontends is within the purview of documentation tooling that consumes symbolgraphs; i am inclined to say that is out of scope of this review. (but the symbolgraph representation is very much in-scope, this is something all downstream documentation tooling will be depending on.)

2 Likes

There's a small part of me that almost feels like introducing a different relationship kind like suppressesAutomaticConformanceOf (or just suppresses), since that feels more "graph-like", plus then we get to link to Copyable if it's available. Regardless, it seems like the "proper" representation of this will need to require some extra work on the part of symbol graph consumers, unless we want to get away with printing a limited inheritance clause in the declaration for just the ones with the "without" operator.

I think that including the information about non-copyability is important, since it's something fundamental to how the type works, like a Collection or Sendable conformance.

I believe that any new language or library feature should take into consideration how it is taught and documented, both on a high level (talking about the feature as a whole) and at a low level (how individual instances of this could appear in documentation, how individual instances should talk about their use of the feature, etc). The most powerful language feature could have an excellent tutorial, but if you can't surface that information on a case-by-case basis in a straightforward way, e.g. in documentation, then it can be cumbersome to understand why you get isolation errors when interacting with a certain type (because you don't know that it's an actor), whether or not you actually need to implement a certain protocol requirement, stuff like that. It's a bit more complicated since there's not really one canonical documentation tool, but we can take these concerns into account when we design and implement language features.

4 Likes

i think it is a stretch to call Copyable a symbol, it has no declaration, it cannot have members or requirements, it cannot be retroactively-conformed to, it is built deeply into the compiler, and users cannot define their own Copyableoids. after all, what is the phylum of Copyable? it is not a “normal” protocol, the closest relative i can think of is AnyObject, which shows up as a “typealias”.

i agree, but i don’t think packaging it like a “normal protocol” would be helpful. it is an intrinsic characteristic of the non-copyable symbol, it is not a relationship between some two symbols.

i don’t forsee surfacing this information to be a major challenge, as long as it has a sensible representation in the underlying symbolgraph. there are some symbol characteristics (like “concrete type member implements protocol requirement”) that are very non-trivial to surface in documentation frontends, even with a great symbolgraph representation. but ~Copyable is unlikely to be one of them.

1 Like

I agree – I don't think it makes sense to introduce a new way of showing relationships just for this scenario. There's already precedent in the symbol graph for representing information like this and we should follow it here.

I think "suppressesConformanceTo" would pair well with the existing "conformsTo". Putting this information in the relationship section will allow documentation tools to render the information however they like and include a link to Copyable like you mentioned. It would also allow the documentation for Copyable to list "Non Conforming Types" like is done for types that conform to Sendable: Sendable | Apple Developer Documentation.

1 Like

Ah. That's good to know – thank you. If Copyable isn't viewed as an actual protocol my opinion might change here.

2 Likes

“Conforming Types” has important scalability issues, it is very challenging to generalize it to a multi-target documentation model; it needs pagination, privilege levels, client version pinning, and some consideration paid to privacy/safety aspects. “Conforming Types” is obviously a core feature of any swift documentation engine, so these scaling problems have to be overcome anyways. but ~Copyable is intrinsic, so it does not have scalability problems to begin with. why overcomplicate it by hitching it to “Conforming Types”?

1 Like

AnyObject is a typealias to Builtin.AnyObject, but that is immaterial really—the question is what it aliases.

Both AnyObject and Copyable are layout constraints, the only official ones in the language, and as the documentation for AnyObject makes clear ("The protocol to which all classes implicitly conform"), they are presented in the surface language as bona fide protocols. Semantically, it does not make sense to allow retroactive conformances to a layout constraint of course. BitwiseCopyable (however it is eventually named) as envisioned would follow this precedent.

It would be unfortunate to make any decisions here that are based on the notion that there is something different about Copyable than other protocols. I doubt we would want to take a different approach with ~Copyable than with, say, ~Equatable, ~Hashable (for enums that currently implicitly conform).

10 Likes

Sorry for this lengthy post but I got carried away:

I wouldn't necessarily exclude Copyable from having a declaration or being used as an existential (which is what I think you mean by Copyableoids). While the exact details about what Copyable really is is left to a forthcoming proposal (along with generics and noncopyable vlues), Copyable could in some respects be a typealias of Any. SE-390 only describes what ~Copyable means for a type in its inheritance clause.

I'd say AnyObject, Sendable, and Copyable are three loosely related concepts in the language, but not fully protocols. What they all share in common with protocols is their ability to constrain a type variable. My interpretation of a protocol, in its purest form, is something that describes the types of a subset of its conformer's non-private members. In this sense, a protocol doesn't tell you about the conformer's in-memory representation (structs and classes can conform) or what private members it has (implementation detail). Conceptually, protocol requiring a private member to exist doesn't make sense, as that member is not going to be accessible via the protocol. An empty protocol doesn't tell you anything about its conforming types; it just creates a new type functionally equivalent to Any.

Ways AnyObject isn't quite a protocol: it allows you to assume its values have a uniform representation in memory as a class object. For example, when you dynamically cast a struct value to AnyObject, it gets boxed up into an implementation-defined class (called SwiftValue). There's no reason to ever explicitly state conformance to AnyObject, nor emit "conformances" to it, since it's an aspect of a type that can always be determined solely by looking at the type's interface in a module (in fact, its members are irrelevant, just "is this a class/actor?"). Thus, it's not necessary (and in fact, disallowed) for programmers to explicitly state AnyObject conformances. Only protocols can inherit from AnyObject, to make them "class-only" protocols.

Implementation Note

Within the compiler, AnyObject has no declaration and is "built-in" as a specially-marked empty protocol composition (like Any). Of course, there's no reason it has to be that way. It totally could be re-implemented as a protocol declaration in the stdlib, with some special treatments. But if we were to reimplement it, I think it'd be better phrased as some new concept, like a "layout constraint", that remains a built-in notion.

Sendable allows you to assume that it's safe to share the value between tasks, but the reasons why it's safe varies depending on the kind of conformer. For example, a class can't be Sendable if it has a mutable private stored property. So Sendable is not quite a protocol either. But unlike AnyObject, a record of whether a specific type can be considered Sendable is needed because you cannot determine based on a type's module interface alone. I believe this is what led to marker protocols: no stated requirements, thus no runtime metadata, but types must state whether they conformed.

We're still building-out what Copyable is, but here are my thoughts: For both programmer convenience and backwards compatibility reasons, we have to be able to determine whether a type is Copyable without having any explicit "conformances" written in a type's interface. So this has flavors of AnyObject. On the other hand, a type must be able to explicitly opt-out via ~Copyable, and that opt-out has to simply be recorded in the module. So the opt-out has flavors of Sendable. In addition, we'd eventually want ~ to work on more things than Copyable, so that probably blurs the line even further.

But sometimes blurring the lines is OK. I don't think ordinary programmers need to worry about precisely what these things are. They only need to understand how to use them, and "protocol" terminology is a fine starting point.

1 Like

We have always insisted, for the purpose of Swift Evolution at least, that a protocol is something that one can employ in writing a useful generic algorithm; it has semantic requirements in addition (or even in spite of not having) syntactic requirements.

With this criterion, we've justified why Error is a protocol (even as it has no required members without defaulted implementations), and why we describe Sendable as a "marker" protocol. It is without a doubt that Sendable is a very useful generic constraint.

We've also used this criterion to decide what's not to be a protocol in the development of the hierarchy of numeric protocols—because protocols don't abstract over just "bags of syntax" (i.e., just some arbitrary shared subset of types' public members), we rejected having things like Divisible that would glom together floating-point and integer division, or Addable that could arguably even include String, etc. (Namely, it would be more likely than not that an algorithm which divides without knowing whether it's integer division or floating-point division is just wrong rather than a useful operation, and it would be bonkers to write something like a + b in a generic context that either appends to a string or adds to an integer and doesn't care which it's doing.) *

Along these lines, should (and I expect it would) Copyable be justified as an addition to the language independent of ~Copyable, it would be in the context of a generic constraint so that we can usefully write algorithms that operate on values of any copyable type and exclude noncopyable ones; that, in and of itself, justifies its being a protocol in the way we have always thought of it here.

* Application of this criterion goes some way to rationalizing why @propertyWrapper and @resultBuilder are spelled as attributes and not conformances to some hypothetical PropertyWrapper and ResultBuilder. All result builders, for example, will have some subset of build* functions (they share overlapping subsets of members), but it is hard to imagine someone writing a useful algorithm generic over all result builders (outside of a code generation or reflection context, perhaps, but those have dedicated libraries to support those use cases that are different in kind from what we're talking about here).

6 Likes