Skip to content

Instantly share code, notes, and snippets.

@erica
Last active March 29, 2018 21:49
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save erica/f749ae15e13ecde5e2762fe91a0ea149 to your computer and use it in GitHub Desktop.
Save erica/f749ae15e13ecde5e2762fe91a0ea149 to your computer and use it in GitHub Desktop.

Introducing Role Keywords to Protocol Implementations to Reduce Code Errors

Introduction

This proposal eliminates several categories of user errors. It mitigates subtle, hard-to-find bugs in Swift protocol code that compile without warning. Introducing "role" keywords, that is explicit keywords that document code intent and support compilation feedback, will increase protocol safety and enable the compiler to test for issues by matching desired behaviors against actual code. A role describes the purpose of a member, whether it is satisfying a protocol requirement, extending a protocol's functionality, overriding an existing satisfaction, or preventing further overrides.

This proposal was first discussed on the Swift Evolution list in the [Pitch] Requiring proactive overrides for default protocol implementations. thread. This version has been modified to limit scope and application, with type-implementation impact moved to a possible second proposal. This new version was discussed in the [Pitch] Introducing role keywords to reduce hard-to-find bugs thread.

Motivation

Protocol extension members do one of two things:

  • They can satisfy a required member declared in that protocol or inherited from another protocol with a default implementation, or
  • They can introduce functionality that is not mentioned in the protocol or its ancestors.

Consider this protocol and extension. It compiles successfully yet contains three potential errors:

protocol ExampleProtocol {
   func thud(with value: Float)
}

extension ExampleProtocol {
    // 1: Near-miss type
    func thud(with value: Double) { ... }
    
    // 2: Near-miss name
    func thus(with value: Float) { ... }
    
    // 3: Accidental satisfaction, intended as extra method
    func thud(with value: Float) { ... }
}

Errors 1 and 2 represent near-miss implementations. The first uses the wrong parameter type (Double). The second misnames the method (thus). Neither satisfies the protocol requirement with a default implementation as intended. Instead, they provide extended functionality that falls outside the protocol requirements. However both are bugs and will compile without warning.

Error 3 represents an accidental default implementation. Possibly implemented in a separate file, away from the protocol declaration, the coder intends to adds an extra method and accidentally satisfies a protocol requirement. This can introduce a subtle bug to adopting types who may not intend to overlook its implementation, and did not mean to inherit this default, which is likely tied to unrelated semantics.

Error 4 occurs when a coder updates a protocol member name, as demonstrated in the following sample.

protocol ExampleProtocol {
   func thump(with value: Float) // formerly thud
}

extension ExampleProtocol {
    // 4: Orphaned default implementation after rename
    func thud(with value: Float) { ... }
}

Error 4 represents an situation where the intended protocol default implementation no longer satisfies the protocol requirement. Renaming a method in the protocol and forgetting to rename the default implementation can lead to hard-to-spot bugs and hidden behavior changes instead of a clear error. Error 4 is most likely to surface in the absence of a conforming type, for example in frameworks and early development

All of these errors are "ghosts". The compiler does not pick up on or respond to any of these mismatches between coder intent and protocol code.

Similar bugs arise in conforming types:

class/enum/struct ExampleType: ExampleProtocol {
   // 5: Accidental override/shadowing
   func thud(with value: Float) { ... }
   
   // 6: Orphaned implementation after rename
   func thud(with value: Float) { ... }

   // 7: Intended conformance
   func thus(with value: Float) { ... }
}

In Error 5, a coder may not be aware that they are overriding an existing implementation when they conform to a protocol and then implement one of its requirements. Choosing a type-local implementation over a protocol extension-supplied one should be an intentional act.

Error 6 demonstrates the same orphaned implementation as Error 4. In this case, it occurs in the type and should be offered the same safety guarantees as an orphaned extension member.

Error 7 mirrors Errors 1 and 2. Swift currently offers language support to suggest near misses. It does not provide intent annotation for the role this member should play in protocol satisfaction. Without it, Swift may warn wrongly when a near-miss name is intended as a standalone member. Annotation can suppress Swift's "helpful" feedback.

Proposed Solution

This proposal introduces keywords, nominally called required, extended, final, and override to eliminate these errors. The names of these keywords will need to be bikeshedded. We've chosen names for this proposal that are most descriptive and in-line with existing Swift conventions.

Under this system, coders can annotate protocol extensions to ensure compile-time detection of all these problems.

  • A required member satisfies a protocol requirement. It can do so at the type or extension level. It may not override an already-existing member of the same name.
  • An extended member adds functionality to a protocol without it being named in the protocol declaration.
  • You override an already-satisfied requirement or an existing extension with a same named member.
  • Any member annotated with final cannot be overridden.

The following example demonstrates how the compiler responds to each of the errors enumerated in the previous section. Although the keywords can be omitted from the following code, including them enables the compiler to act on intent and expose these errors and fixits.

extension ExampleProtocol {
    // Error 1
    // Error: Does not satisfy any known protocol requirement
    // Fixit: replace type with Float
    public required func thud(with value: Double) { ... }
    
    // This next line includes the same error as Error 1 
    // but the compiler could not pick up on it because the 
    // auditing `required` keyword is not included:
    
    // public func thud(with value: Double) { ... }
    
    // Error 2
    // Error: Does not satisfy any known protocol requirement
    // Fixit: replace name with `thud` 
    // (Using nearest match already implemented in compiler)
    public required func thus(with value: Float) { ... }
    
    // Error 3
    // Error: Name overlaps with existing protocol requirement
    // Fixit: replace `extended` keyword with `required`
    // Fixit: rename function signature with `thud_rename_me`
    //        and `FIXME:` annotation
    public extended func thud(with value: Float) { ... }
}

// Error 4
// Demonstrating where the protocol updated a member name
// from `thud` to `thump`. The `required` implementation is 
// no longer properly named.
extension ExampleProtocol { 
    // Error: Does not satisfy any known protocol requirement
    // Fixit: replace `default ` keyword with `extended`
    public required func thud(with value: Float) { ... }
}

Note: Swift cannot provide a better fixit under this proposal for the final error. Swift does not provide an annotation mechanism for previous API decisions. That kind of annotation approach (presumably implemented through documentation markup) is out of scope for this proposal.

The override keyword eliminates accidental conformance and typographic errors.

class/enum/struct ExampleType: ExampleProtocol {
   // Error 5
   // Error: The coder has accidentally provided a same-name
   // member for a requirement or extension without annotation
   // Fixit: Insert `override`
   func thud(with value: Float) { ... }
   
   // Error 6
   // Error: Orphaned implementation after rename
   // Fixit: Remove `required` (or `override`) keyword as this member
   // does not satisfy (or override) any known requirement, extension,
   // or supertype member.
   required func thud(with value: Float) { ... }

   // Error 7
   // Error: User intended conformance but mistyped name or types
   // Fixit: Present near-miss and insert `required` or `override`
   // as suitable to the circumstances.
   func thus(with value: Float) { ... }
}

The final keyword can be added to any member of a protocol extension. It mandates that the member cannot be overridden by a conforming class:

extension ExampleProtocol { 
       // This member satisfies the `thud` requirement for
       // all conforming members. The `final` keyword ensures
       // it cannot be overriden, either intentionally or
       // accidentally.
       public required final func thud(with value: Float) { ... }

class/enum/struct ExampleType: ExampleProtocol {
   // Error: member is already satisfied and cannot be overriden
   // Fixit: remove or rename this member.
   required func thud(with value: Float) { ... } // or
   override func thud(with value: Float) { ... }

Finally a pair of miscellaneous circumstances not covered above:

   // Error: `thud` is already present in a protocol extension.
   // Fixit: replace `required` with `override`
   required func thud(with value: Float) { ... }
   
   // Error detected by compiler. `thud` satisfies a protocol
   // requirement but is not annotated by `required` or `override`
   func thud(with value: Float) { ... }
}

Protocol Inheritance

In Swift, a derived protocol can add a requirement for a member that's already been added as extended functionality in a parent protocol. Swift follows the "closest implementation wins". A type conforming to B uses the B.bar() implementation. If a B extension does not supply a bar implementation of its own, it inherits the extend version from A.

This proposal clarifies but does not change Swift’s extension method dispatch rules. A value whose compile-time type conforms to B uses the B.bar() implementation. In the following example, the bar method extends A but provides a required implementation in B:

protocol A {
  func foo()
}
extension A {
  extended func bar() { ... }
}
protocol B: A {
  func bar()
}
extension B {
  // `required` should be replaced here by `override`
  required func bar() { ... }
}

Because B's requirement has already been satisfied by conforming to A, the extension should be annotated override. If A's implementation was marked as final, B should not be allowed to implement bar at all.

Edge Cases

Consider the following two protocols with an overlapping requirement:

protocol A {
    func foo()
}

protocol B {
    func foo()
}

If A creates a default implementation and a type conforms to both A and B, its implementation is an override, even though B does not supply a default implementation.

extension A {
    func foo() { ... }
}

class/enum/struct SomeType: A & B {
    override func foo()
}

It would be handy for the conforming type to be able to refer to the default implementation, for example: A.foo(). This enables the type to differentiate the two implementations and access both, just as you would with super.

Protocol A may not implement foo directly if it descends from another protocol. For example:

protocol Parent {}
extension Parent {
    func foo() { ... }
}
protocol A: Parent

class/enum/struct SomeType: A & B {
    override func foo()
}

If both A and B provide conflicting implementations of foo, the compiler must report an error unless the conforming type provides an override, to resolve the conflict. Keep in mind that either or both of the implementations may be final, disallowing an override.

Source compatibility

  • As optional "best practices", these changes do not affect existing Swift code. It should be easy for a migrator pass to offer to introduce the keywords to enhance code safety.
  • If introduced as non-optional keywords, the impact would be profound and large.

Effect on ABI stability

TBD

Effect on API resilience

TBD

Alternatives and Future Directions

  • This proposal does not make role keywords mandatory. Swift would be safer if role annotation were required. The compiler could be adapted to introduce Fixits for this and the migrator updated to accommodate.

  • If the Swift community were willing to accept heavily warned code without breaking, the extended keyword could be omitted. All keywords are needed to ensure that current Swift code will not emit warnings. Requiring defaulted for any default implementation distinguishes a dynamically dispatched protocol-sourced method from statically dispatched methods that extend a protocol.

  • The Swift compiler can generate warnings for methods in protocol extensions that are not annotated, with a proper Fixit. An opt-in compiler flag would be nice, but the team has enforced a consistent policy of avoiding compiler flags.

  • In early versions of Swift 2 betas, protocol extension methods were required to specify final to exclude dynamic dispatch. This syntax was more confusing than useful after protocol extensions were allowed to fulfill requirements. final was removed before the 2.0 GM but it remained valid syntax.

    SE-0164 removed support for final in protocol extensions as it had no semantic meaning. An alternative to this proposal could revert this change, allow final in extensions and push these warnings to a linter.

Acknowledgements and Thanks

Thanks, Doug Gregor, Jordan Rose, and Joe Groff

Related reading

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment