PrePitch: Memberwise protocol requirement synthesis for structs

After seeing lots of discussion on the forums recently about what a general approach to protocol requirement synthesis might look like, I decided to explore a few ideas. The following is a (very early/rough) look at how I think such a feature might work. I'd be interested to hear any and all feedback about whether this seems like a good approach or general direction to take!

I will note that this pitch intentionally avoids relying on future language features like variadic generics which could potentially improve the ergonomics (but increase implementation complexity quite a bit).

Memberwise Requirement Synthesis

The goal of memberwise requirement synthesis is to synthesize a protocol requirement implementation for a type if all of its stored properties conform to the protocol. A protocol requirement can be marked as eligible for derivation by adding the @memberwiseDerivable attribute. The attribute takes three parameters: a mapper function, a reducer function, and an initial value for reduction. An example if this was applied to Equatable might look like this:

protocol Equatable2 {
  @memberwiseDerivable(mapper: equatable2Mapper, reducer: equatable2Reducer, reduceInitialResult: true)
  static func == (_: Self, _: Self) -> Bool
}

// lhs and rhs come from the derived requirement, memberKeyPath is added to the argument list
// the return type must match the reducer's argument types
// Self is replaced by generic parameter T, U is the type of the member (which conforms to the given protocol)
func equatable2Mapper<T, U: Equatable2>(_ lhs: T, _ rhs: T, memberKeyPath: KeyPath<T, U>) -> Bool {
  return lhs[keyPath: memberKeyPath] == rhs[keyPath: memberKeyPath]
}

// the argument types must match the return type of the mapper
// the return type must match the return type of the derived requirement
func equatable2Reducer(_ first: Bool, _ second: Bool) -> Bool {
  return first && second
}

Using the provided parameters, the compiler could then synthesize a conformance for a type which doesn't provide an implementation, which would look like this:

// Example memberwise conforming type
struct Compound: Equatable2 {
  let a: A = A() // Conforming type
  let a2: A = A() // Conforming type
  let b: B = B() // Conforming type
  
  // Compiler Synthesized requirement, not written by user
  static func == (lhs: Compound, rhs: Compound) -> Bool {
    return equatable2Reducer(equatable2Reducer(equatable2Reducer(true /* reduceInitialResult expression from attribute */,
      equatable2Mapper(lhs, rhs, memberKeyPath: \Compound.a)),
      equatable2Mapper(lhs, rhs, memberKeyPath: \Compound.a2)),
      equatable2Mapper(lhs, rhs, memberKeyPath: \Compound.b))
  }
}

I’ve uploaded a gist here with examples of how Equatable and Hashable requirements could be synthesized with this feature. It includes the code which would be inserted by both the user and the compiler.

Apologies for the long post, but I think its also worth answering a few of the questions I’m sure will come up:

Q: Why build the synthesized requirement from separate map and reduce steps?
A: I initially struggled to come up with a design which could preserve type information in the member key paths without relying on some form of variadic generics. Having a single output type from the map phase to pass off to the reduce phase sidesteps that issue. Also, most use cases for synthesized conformances that I’ve thought of so far fit well into the map-reduce model, because members are usually operated on one by one (This is true for Equatable/Hashable/AdditiveArithmetic/Comparable).

Q: Would this work for enums?
A: Not currently. In the future, this could probably be extended relatively naturally to cover enums if they gain KeyPath support

Q: How would this method compare to the current conformance synthesis built into the compiler performance-wise?
A: I don’t know yet :man_shrugging:. If the map/reduce functions could be inlined I think we’d be able to generate code roughly equivalent to the existing compiler synthesis, but it’s too early to say for sure.

Thanks for reading! I know this isn't very detailed yet for a pitch, but I thought it was worth starting the discussion around how we could make synthesized conformances a first-class feature in the language.

5 Likes

Where do the key paths that will be fed into the mapper function come from? This idea blends into a thread I posted on automatic conformance for tuples. If your algorithm comes across a product nominal type with tuple members, instead of just posting a compile-time error that the type can’t conform, pierce the tuple by not including the tuple’s key path, but the key paths for all of its constituents instead. (If a constituent is a nested tuple, it’s recursively pierced and replaced too. So an tuple member causes the outer nominal type to fail only if the tuple has at least one inner non-tuple member that can’t qualify.)

Have you thought about the Codable family? I covered that in another branch of my thread.

The key paths are passed by the compiler as part of its synthesized implementation of the requirement (so no reflection is required at runtime). You make an interesting point about tuple types, thanks for linking to that thread!. I haven't put a lot of thought into handling them, but since tuples support key paths now it should be possible for the compiler to pierce them when generating the list of key paths in the synthesized implementation.

Codable is tricky because its synthesis is much more complicated than the other protocols synthesized by the compiler today due to CodingKeys. I'm not sure it should be a goal of the initial proposal (if this becomes one) to support a full replacement of Codable synthesis right away.