Protocol Witness Matching Mini-Manifesto

Hello Swift community,

I have been working on a "mini-manifesto" whose aim is to set out basic design principles around protocol witness matching and get some agreement from the Swift community on it. One of the reasons why is because I am currently pitching a new form of witness matching and while we've had some discussion on this forum on different kinds of matching (like subtyping, etc), it would be great to have a small document which people can refer to during discussions and use it to create new proposals to lift existing limitations.

This document is currently a draft so I would appreciate your thoughts and feedback on it. Thank you!

Updated version here: https://github.com/apple/swift/pull/29617


Protocol Witness Matching Manifesto (Draft)

Author: Suyash Srijan

(Note: This is a work-in-progress design document)


Introduction

In Swift, protocol witnesses have to match exactly with a protocol requirement. For example:

protocol NetworkClient {
  func fetchRequest(request: URLRequest, numRetries: Int, completion: @escaping (Result<Data, Error>) -> Void)
}

class MyNetworkClient: NetworkClient {
  func fetchRequest(request: URLRequest, numRetries: Int, completion: @escaping (Result<Data, Error>) -> Void) { ... } // Okay
  func fetchRequest(request: URLRequest, numRetries: Int, completion: (Result<Data, Error>) -> Void) { ... } // Also okay
  func fetchRequest(request: URLRequest, numRetries, someArgument: Int = 0, completion: @escaping (Result<Data, Error>) -> Void) { ... } // Not a match
}

protocol ViewContainer {
  var base: UIView { get }
}

class MyViewContainer: ViewContainer {
  let base: UIView // Okay
  let base: MyCustomUIViewSubclass // Not a match
}

protocol FileDecoder {
  func decode() throws -> Data
  func encode(using encoder: FileEncoder)
}

class MyFileDecoder: FileDecoder {
  func decode() throws -> Data { ... } // Okay
  func decode() -> Data { ... } // Also okay
  
  func encode(using encoder: FileEncoder) { ... } // Okay
  func encode(using encoder: FileEncoder) throws { ... } // Not a match
}

with certain exceptions, such as:

  1. A non-throwing function can witness a throwing function requirement.
  2. A non-failable initializer can witness a failable initializer requirement.
  3. A non-escaping closure can witness one that is marked @escaping.

Now, while some of the rules around matching a witness to a requirement are reasonable, others might argue that some of them are too restrictive (such as disallowing subtyping, which is already allowed in classes). This leads us to ask a very basic question - what kinds of differences are reasonable when matching a protocol witness to a protocol requirement?

This manifesto lists some possible changes we can make to the current model and set out some basic design principles. Some of these changes have been discussed many times in the past on the forums and while there is no guarantee that all of these changes will be implemented (either now or in the future), this document will serve as a starting point for discussions around witness matching (such as discussions around potential designs of how a certain feature mentioned here should work, whether its the right thing to do, etc) and something the community can use to create and publish evolution proposals.

Likely

These are restrictions that are currently enforced by the compiler which are not unreasonable to be allowed and doing so is only a matter of doing the implementation work and does not require any changes to the current syntax.

These are also features that have been requested from time to time and can help improve the expressivity of the language, without fundamentally changing it.

Desugaring

We can enable certain forms of "desugaring", such as allowing enum cases to satisfy static requirements:

For example:

protocol Number {
  static var zero: Self { get }
  static func one(times: Self) -> Self
}

enum MyNumber: Number {
  case zero
  indirect case one(times: Self)
}

let foo: some Number = MyNumber.one(times: .zero)

Enum cases already behave like static properties/functions in the language, both syntactically and semantically, so it's reasonable to think of a case as "sugar" for it.

Related bugs:

Default arguments

We can allow functions with default arguments to witness function requirements:

protocol NetworkClient {
  func fetch(request: URLRequest)
}

final class MyNetworkClient: NetworkClient {
  func fetch(request: URLRequest, retryCount: Int = 3) {
    ...
  }
}

Related bugs:

  • None

Subtyping

We can allow protocol witnesses to be covariant (i.e. have subtypes of the requirement):

protocol Scrollable {
  var base: UIView { get }
  func getView() -> UIView
}

final class MyScrollView: Scrollable {
  let base: MyCustomSubclassOfUIView
  
  func getView() -> AnotherSubclassOfUIView { 
    ...
  }
}

We already allow this in classes, so it probably makes sense for protocols witnesses to follow the same rules that exist for classes, for example, optional sub-typing:

class A {
  func getNumber() -> Int? { return 0 }
}

class B: A {
  override func getNumber() -> Int { return 0 }
}

As we loosen the rules around subtyping and other kinds of matching, it is possible that associated type inference could degrade and it is important to keep that in mind. There have been some ideas around how to improve it though, such as ones mentioned in this post.

Related bugs:

Old PR with possible implementation: https://github.com/apple/swift/pull/8718

Maybe/Unlikely

These are features where either it's not clear whether it belongs to the language or provides any concrete benefits, or it complicates the existing design or introduces other implementation/runtime complexity.

Properties with function type

We could allow a function to match a property protocol requirement with a function type. For example:

protocol Fetchable {
  var fetch: () -> Data
}

struct MyFetchable: Fetchable {
  func fetch() -> Data { 
    ... 
  }
}

or vice versa - letting a function requirement be witnessed by a property with function type:

protocol Fetchable {
  func fetch() -> Data
}

struct MyFetchable: Fetchable {
  var fetch: () -> Data { 
    ... 
  }
}

Syntactic matching

We could allow anything that meets the syntactic needs of the requirement to witness that requirement. For example:

protocol Mathable {
  static func isEven(_ instance: Self) -> (Int) -> Bool
}

struct Math: Mathable {
  func isEven(_ number: Int) -> Bool {
    ...
  }
}

We could go to the extreme and allow other kinds of matching, such as allowing @dynamicMemberLookup to satisfy arbitrary protocol requirements.

9 Likes

It seems to me that one opposite example is missing:

protocol Fetchable {
  func fetch() -> Data
  func fetch(foo: Foo) -> Data
}

strict S: Fetchable {
  var fetch: () -> Data { ... }

  // when compound names are supported
  var fetch(foo:): (Foo) -> Data { ... }

  // or with syntactic sugar
  var fetch: (foo: Foo) -> Data { ... }
}
2 Likes

Also I‘d love to see a generalization over Optional being a super type.

This is related:

1 Like
Corrected mistake

Small correction: What you wrote is fine. What isn’t allowed is using a defaulted argument to match a requirement with no argument at all:

protocol NetworkClient {
  func fetch(request: URLRequest)
}

final class MyNetworkClient: NetworkClient {
  func fetch(request: URLRequest, retryCount: Int = 3) {
    ...
  }
}

Oops, thank you for spotting my mistake. I'll fix it!

1 Like

I think Fetchable example is also incorrect, because MyFetchable should be a conforming type, no?

Thank you, I have fixed it now!

2 Likes

This rule would likely be arbitrary subtyping, which includes optionals, like it is with class overrides.

4 Likes

Moving the discussion from the other thread into here:

So either the default argument feature from above cannot be reduced to zero arguments or I still don‘t understand something here.

protocol P {
  static func foo() -> Self
}

struct S: P {
  static func foo(_ s: S = .init()) -> S { s } // #1 is this okay?
}

enum E: P {
  case foo(Void = ()) // if #1 is okay then this should be okay as well
}

Are you thinking of case foo(Void = ()) to be the same as just a case foo (or do you want it to be the same)? Because they are not the same at the moment:

enum Foo: Equatable {
  case bar1(Void = ())
  case bar2

  // Probably not needed when tuples are equatable
  static func == (lhs: Self, rhs: Self) -> Bool {
	switch (lhs, rhs) {
	  case (.bar1, .bar1): return true
	  case (.bar2, .bar2): return true
	  default: return false
    }
  }
}

let b1 = Foo.bar1()
let b2 = Foo.bar2

b1 == .bar1 // error
b1 == .bar1() // okay
b2 == .bar2 // okay, as usual

Again, I‘m not talking about a payload less enum case at all. My thinking path is that case foo(Int = 42) would satisfy static func foo() -> Self if #1 in the above example would be valid.

Then you can do:

func test<T: P>(_: T.Type) -> T {
  T.foo()
}

let e = test(E.self)
// E is from an example upthread, it‘s payload has Void not Int, 
// but it does not matter, in can be anything
e == .foo() // assuming Void is Equatable

Okay, sorry I misunderstood what you were talking about. I think it might be okay for that to work (i.e. a function with all default arguments can witness a function which takes no arguments).

1 Like

@suyashsrijan I think this would be important to incorporate into the text of the mini-manifesto. There is also a prior PR, never merged and closed for inactivity, to make this work in the case of optionals, which would be good to link to for reference.

2 Likes

Ah yes, I remember that PR. I have updated the text now, thank you!

One thing to keep in mind: as the witness matching rules get more complex, it has a spillover effect on associated type inference rules becoming more complex and possibly less predictable.

4 Likes

Is that still true if we push through the changes to inference that @Douglas_Gregor pitched last year?

Are you referring to this? I do remember associated type inference concerns being raised in the past whenever the topic of covariance has come up. I’m not familiar with the implementation so I’m not sure how exactly one would go about solving it (maybe Doug has some ideas?) but I’ll add Slava’s point as a note in the document so it can be referred to in the future.

Nope, I was referring to this: [RFC] Associated type inference. Looks like it was actually a couple years ago now.

1 Like

I still think we need to nail down associated type inference to make it more predictable and more efficiently implementable. Loosening the witness matching requirements might make the current system a little more complicated, and we should be mindful of that... but we shouldn't let it get in the way of improving on this area.

Doug

4 Likes

I propose to write a pattern below in Introduction section.

protocol P1 {}

struct S1: P1 {}

protocol P2 {
    func foo(_ p1: S1)
}

struct S2: P2 {
    func foo<X: P1>(_ p1: X) {}
}

Generic function can conform to requirements.
This technique is useful sometime.

1 Like