Pre-Pitch: Explicit protocol fulfilment with the 'conformance' keyword

+1

I hit this again the other day. We have really strange witness matching that often feels inconsistent.

For example, if you have a protocol requirement which accepts a concrete type, a generic method can witness it:

protocol MyProtocol {
  func doSomething(_: String)
}

struct MyStruct: MyProtocol {
  // Works, because String conforms to StringProtocol
  func doSomething<S: StringProtocol>(_: S) { fatalError() }
}

So you might expect, if there is a protocol requirement which returns an existential, that a method which returns a concrete conforming type would also be able to witness it:

protocol MyProtocol {
  func doSomething() -> any StringProtocol
}

struct MyStruct: MyProtocol {
  // Computer says no.
  func doSomething() -> String { fatalError() }
}

If the requirement has a default implementation, the type still conforms, but resolves to an unexpected implementation :frowning:

The idea of only warning about these mismatches in extensions does not seem like an adequate solution, to me. Some types are designed to perform one task and be written in a single, compact declaration - so it is unusual that anybody would split the implementation up across extensions, and hence they would not receive any diagnostics. Types declared inside functions don't support extensions at all:

func someFunction() {
  struct MyStruct {}
  extension MyStruct {}
  ^^^^^^^^^
  error: declaration is only valid at file scope
}
4 Likes

Maybe this is what you are looking for: Pre-Pitch: Explicit protocol fulfilment with the 'conformance' keyword - #14 by gwendal.roue

extension T {
     // Requires any conformance
     conformance func foo() { ... }
}

extension T: MyProtocol {
     // Requires a MyProtocol conformance
     conformance func foo() { ... }
}

The pitch has not been amended with this feature yet - I'm still not sure it should belong to the pitch at this point.

As another data point, there is mutating for mutating func other property accessors so conforming could play a decent role here too

If a type T continues to fulfill the protocol requirements of protocol P, but contains extra APIs that are no longer necessary for conformance, this is not a correctness issue. Presumably, those APIs were implemented in a way that is semantically correct for type T, and they will continue to function correctly when called by users whether or not they are required by P because they make sense for the concrete type T.*

If those APIs never made sense for T, then T should not have conformed to an earlier version of P in the first place. If there's some narrow circumstance where it was desired that T should not expose some API required by P but for its requirement to conform, but nonetheless T could properly implement those APIs in a semantically sound way, then that's an argument for standardizing the @_implements feature because it would have done better by allowing T to conform to all versions of P without directly exposing the APIs in question under the required name.

Users are free to use any style they want, but it is self-evidently true that expressing the same idea one way in code can be clearer about intentionality than another way.

Near-miss diagnostics for protocol conformances are available for extensions because it is in that context that the user's intentionality is clear enough for those diagnostics not to be nuisances, not because the people implementing the feature decided that they wanted to "favor one particular programming style."

It is fair enough to consider if the design of the language can be changed to improve expressivity so that the user's intention can be made clear more consistently, but I'm skeptical of additions to Swift's syntax that are made expressly because a user doesn't want to write code in a certain style which already solves the problem. In a sense, such accommodation—and not the status quo—is what's favoring a particular programming style.

You are describing today's Swift: a member in an extension declaring a protocol conformance which is a near-miss match for a requirement currently produces a warning that can be silenced by moving the declaration to another extension. Are you making the argument that this warning is actively harmful, and do you have evidence for such harm? Independent of what you are pitching, are you arguing that the current warning should be removed from the compiler?

4 Likes

I am a hard-liner too. I'd say:

extension T {
     // no conformance allowed here
     conformance func foo() { ... } // error
}

extension T: Prot1 {
     conformance func foo() { ... } // from Prot1, ok
     conformance func bar() { ... } // Error, no bar in Prot1
}

extension T: Prot1, Prot2 {
     conformance func foo() { ... } // from Prot1, ok
     conformance func bar() { ... } // from Prot2, ok
}

obviously this as well:

extension T: Prot1 {
     func foo() { ... } // Error, "conformance" keyword required
     conformance func baz() { ... } // not in Prot1
}
1 Like

This is an interesting and very useful case you bring up, which should have a good solution.

I haven't tried it out, but I would have thought that protocol requirements can have availability annotations; certainly, that is what I would have reached for if confronted with such a scenario. Does Swift currently support—and if not, would it have solved your problem here if it did support—the ability to label customization points as deprecated or obsolete? Conforming types which implement such an API would then get the specified warning/error.

Can you elaborate on this a bit? Is static var databaseSelection: [any SQLSelectable] { get } a requirement of your protocol? If so, and a conforming type doesn't implement it because of a near-miss, how is Swift allowing the code to compile despite an invalid conformance?

No, it is not.

For example, GRDB exposes several "record protocols" that user types can adopt, for converting themselves to or from database row, as well as feeding an SQL query builder from the name of a table. The PersistableRecord protocol has deeply modified its customization points in GRDB6. Previously, users were able to provide a custom implementation of methods such as insert. They must no longer do that, due to the breaking changes. Instead, the customization points are now named willInsert, didInsert, etc.

The conformance has always been intended by the programmer, and correctly implemented.

But the protocol has changed, and programmer's code has to be updated. The consequences of a breaking change. Breaking changes happen in the wild.

If the conformance keyword would exist, the compiler would perform, for the programmer, the exhaustive listing of methods that have to be reconsidered:

struct Player: PersistableRecord {
    // After GRDB6 upgrade:
    // error: function insert(...) does not fulfill any protocol requirement
    conformance func insert(...) { ... }
}

Now the programmer has a choice:

  • Keep the method, but remove the conformance keyword, since the programmer must not express the intent to fulfil a requirement, when no protocol requires such a method.
  • Delete the method.
  • Delete the method and reimplement the feature with the new apis.

What's important is that the program won't compile until the programmer has made this choice. That's the security brought by conformance.

I think Karl has well explained that some types can't provide conformances with extensions:

I understand that you like extensions, but this pitch really wants to provide useful features to users who don't, or can't, or did not learn to group conformances in extensions. Call this my personal touch :-)

Now, in several answers above I have extended the original pitch with more features dedicated to such extensions. Example. The idea was further developed in other responses. I think it can be fruitful.

Yes, it is.

If so, and a conforming type doesn't implement it because of a near-miss, how is Swift allowing the code to compile despite an invalid conformance?

Because the requirement has a default implementation. If users don't specify which database columns they want to fetch, the default is to fetch all of them (SELECT *).

This is not what is pitched, as explained above:

  • In this response, you'll see why only relying on extension-that-declare-a-conformance was not chosen.
  • In this response, Karl reminds that some types can not be extended.

This is why the pitch brings value even without such extensions.

However, in several answers above, the idea to give more features to such extensions is explored. The pitch can surely be improved :-)

1 Like

This is certainly tricky! Both this use case and the use case you present above about evolving protocols are certainly very reasonable things where Swift can do better.

The ideal solution, though, is one where your users don't have to do anything (such as using an optional keyword, which you can advise them to do but not enforce); the language should give you as the library author enough tools to express these warnings in order to guide users.

2 Likes

I admit I have often wished that the @available attribute had more possibilities.

Yet @available has consequences on the call site, not on the declarations. This pitch is about declarations. This is a (post-hoc) explanation why I did not try to build on top of @available.

I think it would be reasonable for the language to decide whether it prefers users to group conformances into extensions, and then focus on improving the experience of the preferred approach. I personally prefer the extension approach, but modulo the existing ergonomic difficulties (such as the lack of ability to define stored properties in extensions) I think it's difficult to assert that it's the languages preferred organizational strategy.

For example, there's no reason why the answer to Karl's issue can't be "let's provide a way to extend types declared in functions." IMO it would also be reasonable for the language to take the position of "once you start writing extensions/conformances, you should probably factor the type definition out of the function itself," in which case we could provide a fix-it to move the type out of the function in question.

I consider it a non-goal to try to provide equivalent levels of support for all possible programming styles. It's fine for Swift to be opinionated about the code style that users adopt.

3 Likes

This is not the opinion of the author of the pitch (your faithful). This does not mean that I won't accept that the pitch evolves in the direction you describe. Of course I would - this is a pitch. But I think you have to show what's wrong with the initial "generous" approach.

I repeat myself: in several answers above, the pitch has been extended for conformance extensions (extension T: P { ... }). I'm sure we can enhance the life of everyone.

The initial pitch was not updated yet with those ideas. I hope they settle soon so that I can, because I'm very happy with the current explorations.

3 Likes

I think @xwu has summarized it nicely above:

If it's just a matter of, say, improving diagnostics for already-valid syntax, I think there's usually nothing wrong with an inclusive approach. But the addition of new keywords and syntax has a non-zero cost for Swift users and the ecosystem as a whole, so I don't think it's the case that doing so (in order to accommodate all programming styles) is an obvious win.

The lack of local extensions is just one issue. The actual place where I encountered this was with a schema type - a very small type, where most operations are defaulted, and the expectation is that the user will implement a couple of requirements to customise the behaviour in a way that feels declarative-ish.

// Describes a key-value string with the format:
// key:value,otherKey:otherValue

struct CommaSeparated: KeyValueStringSchema {
  var pairDelimiter: UInt8 { UInt8(ascii: ",") }
  var keyValueDelimiter: UInt8 { UInt8(ascii: ":") }
}

I don't think it's natural to split this up in to an empty struct and a separate extension:

struct CommaSeparated {}

extension CommaSeparated: KeyValueStringSchema {
  var pairDelimiter: UInt8 { UInt8(ascii: ",") }
  var keyValueDelimiter: UInt8 { UInt8(ascii: ":") }
}

And yet - if people write the type in the more compact (and IMO more natural) way, they lose all diagnostics for near-misses with witness matching. It makes these diagnostics almost opt-in, which seems strange given their intended purpose.

7 Likes

The expression "because a user doesn't want to write code in a certain style" is exclusive. Some users surely do not want, but some others do not know, and some others can not (when the language does not allow to use an extension). I won't make a course about inclusivity, because I'm certainly not an expert, but I can spot exclusive language pretty quickly.

I suggest an inclusive approach to the problem. I have given an example related to documentation in a previous post in this thread. Documentation writers make assumptions about their readers, but usually much less assumptions than people who want to enforce a coding style. Count me in the group of documentation writers, and this is reflected in the "generosity" of the pitch.

Before I can foster the exclusive approach, I want to read what's wrong with the inclusive one.

But the addition of new keywords and syntax has a non-zero cost for Swift users and the ecosystem as a whole, so I don't think it's the case that doing so (in order to accommodate all programming styles) is an obvious win.

That's why I, and other people, have tried to give the expected benefits of the conformance keyword.


EDIT: I'm sure we can make progress without the exclusive/inclusive dichotomy. It can help clarifying some ideas, but it is touchy, and can make people nervous, or feel attacked. This is clearly not my intent.

1 Like

I'm actually somewhat fond of this form, because the division more clearly illustrates that the type has no state of its own and that the useful operations come from its conformance to the protocol. I wouldn't go so far as to say it's "not natural" to break it up in this way. "Non-obvious", perhaps.

8 Likes

This would be true (as Xiaodi again notes above) even with the addition of the conformance keyword, or any other optional strategy for enabling these diagnostics. Perhaps the assertion is that it's easier to remember to include the conformance keyword than it is to remember to split a conformance out into a separate extension, but this isn't self-evident to me. Even with override being required for method overrides, I frequently need to be reminded by the compiler to insert it.

I suppose what I'm trying to get at is that a solution which adds language features in order to support all possible organizational styles imposes its own sort of inaccessibility by making the language more complex and codebases (potentially) more different from one another, so it's not really (to me) a clear-cut "inclusive" vs. "exclusive" dichotomy.

Adding a new keyword for a relatively narrow use-case is less compelling to me than thinking about how we can improve existing language features to enable improved protocol witness matching diagnostics as a benefit in addition to other wins.

3 Likes