[Question] Memberwise initializers as pseudo "protocol requirements"

TLDR: what's the most principled way to represent memberwise initializers as pseudo "protocol requirements", such that they work with derived conformances and don't clash with user-defined initializers?


Context: memberwise initializer synthesis is necessary for usable automatic differentiation (work done on the tensorflow branch). There are a few compiler-known protocols (AdditiveArithmetic [implemented in Swift 5 without derived conformances], VectorNumeric, and Differentiable) and derived conformances should work for a struct to one of these protocols when all stored properties conform to the protocol:

// Simple example.
// Note: stored property type `Float` conforms to `AdditiveArithmetic`.
struct Point : AdditiveArithmetic {
  // Derived conformances should work when all stored properties
  // conform to the desired protocol.
  var x, y: Float

  // All protocol requirements should be synthesized via derived conformances.
  // This is critical for usability of these protocols.
  // Synthesized code:
  /*
  static func +(lhs: Point, rhs: Point) -> Point {
    // SEE HERE: Requires memberwise initializer!
    return Point(x: lhs.x + rhs.x, y: lhs.y + rhs.y)
  }
  static var zero: Point {
    // SEE HERE: Requires memberwise initializer!
    return Point(x: Float.zero, y: Float.zero)
  }
  */
}

Memberwise initializers must be identifiable by the compiler in order to support derived conformances to these protocols for aggregate types. This is critical for the usability of these protocols: users need only define stored properties and all requirements are synthesized. Synthesized implementations simply forward the implementations of their members.

Currently, memberwise-initializer-ness is simply identified via a bit (AbstractFuncDecl::isMemberwiseInitializer); only compiler-synthesized initializers are marked as memberwise initializers, user-defined initializers never count.

Derived conformances implemented on the tensorflow branch currently identify memberwise initializers by looking for a ConstructorDecl member where isMemberwiseInitializer == true, and synthesizing a memberwise initializer if one doesn't already exist. However, this alone is insufficient and leads to bugs when users define custom initializers with the same signature:

import TensorFlow

// Note: `Float` conforms to `Differentiable`.
public struct Foo: Differentiable {
    public let bar: Float
    public init(bar: Float) {
        self.bar = bar
    }
}
// Compiler synthesizes a duplicate memberwise initializer:
// invalid redeclaration clash.
test.swift:6:12: error: invalid redeclaration of 'init(bar:)'
    public init(bar: Float) {
           ^
test.swift:4:15: note: 'init(bar:)' previously declared here
public struct Foo: Differentiable {
              ^

Main question: I wonder what's the most principled way to avoid this problem (and in general, to represent memberwise initializers as pseudo-protocol-requirements)?

The simplest solution is to look for user-defined initializers with the expected memberwise initializer signature, and to use them during derived conformances if they exist. We'll proceed with this as our solution for fixing "duplicate initializer" bugs like the one shown above.

However, the main tradeoff is that there's no guarantee that user-defined memberwise initializers actually perform memberwise initialization: they can contain arbitrary code, which may lead to unexpected semantics for derived conformances.

  • For example, Point.zero from above wouldn't return Point(x: 0, y: 0) if there's a user-defined initializer Point.init(x: Float, y: Float) that does something arbitrary like self.x = 1337; self.y = 1337.
  • On the other hand, users can already customize protocol requirements to have arbitrary behavior (e.g. hardcoding Foo.== to return false to mess up derived conformances for structs with a Foo stored property). But since these are protocol requirements, they can come with semantic requirements for implementations. Memberwise initializers OTOH cannot be expressed as protocol requirements.

Some other ideas/considerations:

  • One way to gain confidence that "memberwise initializers" do actually perform memberwise initialization is to use an obfuscated name to prevent confusion with user-defined initializers: static func __memberwiseInitializer(x: X, ...) -> Self. The tradeoff is that it's too ugly for users to call this directly (if it's a goal to expose memberwise initialization functionality to users).
  • It may be nice if memberwise initializers are public so that they can called from other modules.
    • I don't believe this is necessary for automatic differentiation.
    • This is not the current behavior of synthesized struct memberwise initializers, which have internal access.
  • Looking to the future: this problem is about "representing memberwise initializers as requirements", not about synthesis of memberwise initializers, which is trivial and already exists. The addition of macros that enable iteration over struct members (so that derived conformances can be defined using macros) wouldn't help solve this problem. We would need something like SE-18: flexible memberwise initialization, which exposes a memberwise keyword in user syntax and could enable memberwise init protocol requirements.

Any ideas/feedback would be appreciated!

cc @rxwei, @Alejandro who might be interested from his work on memberwise initializers

1 Like

An aside: we're in the process of documenting the design of these protocols and their derived conformances (in addition to documenting the entire autodiff system).

The design is still being refined and feedback is very welcome (though learning about the design may be difficult until documentation is done). Eventually, everything is planned for Swift Evolution.

To get a taste: these protocols and derived conformances enable expressive protocol-oriented differentiable programming. You can learn more about derived conformances specifically from lit tests:

1 Like

If you really want to make it a protocol in today's Swift (plus extra requirement synthesis), you can:

protocol HasCompilerSynthesizedMemberwiseInit {
  associatedtype MembersAsTuple
  static func _initMembers(_: MembersAsTuple) -> Self // synthesized
}

Do I think this is a good idea? I'm not sure. I'm still wondering what problem you're trying to solve by having memberwise initializers, and maybe the real answer is that the protocol isn't "HasMemberwiseInit" or "HasCompilerSynthesizedMemberwiseInit" but "AllowElementwiseOperations" or something. Not every zero type means initializing the fields with zero; not every Numeric operation means applying that operation to the fields.

2 Likes

Thanks for your response!

I think a Has(CompilerSynthesized)MemberwiseInit protocol with an explicit MembersAsTuple type makes sense! It reminds me of protocols like CaseIterable, which has an explicit AllCases type.

I lean towards HasMemberwiseInit actually (relaxing the "must be compiler synthesized" requirement to allow user-defined conformances where MembersAsTuple equals the tuple'd expected memberwise init parameter type) so that _initMembers can be a customization point. Since it's now a protocol requirement, it's reasonable to imbue it with semantic requirements that user implementers should respect.

Tentative new solution: derived conformances code will look for a HasMemberwiseInit conformance (synthesized or user-defined) as a requirement for deriving conformances for AdditiveArithmetic, VectorNumeric, and Differentiable protocols, where the MembersAsTuple type is equal to the tuple'd expected type for a memberwise initializer.

I think more functionality-specific protocols like AllowElementwiseOperations could make sense, though it would have the exact same requirements (MembersAsTuple and _initMembers). I need to think more about this.


The key reason for needing compiler-known memberwise initializers is: it seems crucial in order to enable "deriving conformances to protocol X for structs whose members all conform to X" for AdditiveArithmetic, VectorNumeric, and Differentiable.

  • This functionality exists for existing derived conformances to Equatable and Hashable, but those protocols differ in that their requirements (func == and func hash(into:)) don't require memberwise initialization.
  • I just realized that Codable derived conformances do deal with initialization, and I believe it's handled using the CodingKeys enum. Maybe the new derived conformances can learn from/adapt this design - I need to think about it more. If you have ideas about adapting CodingKeys for the memberwise initializer use case, please comment!

I thought that differentiation might benefit from compiler-known memberwise initializers for TangentVector/CotangentVector structs synthesized during Differentiable protocol derived conformances, but I actually can't think of any reasons.

  • There's something called the "fieldwise product space" differentiation strategy for SIL instructions like struct_extract and struct_element_addr, which requires knowledge of synthesized struct fields, but this doesn't require memberwise initialization.