Does anybody else miss optional requirements on protocols?

Hi,

In Objective-C it was possible to mark a requirement (is that the right word? - function member of a protocol definition) as @optional, meaning it was part of the protocol but conforming types didn't need to implement it. This seemed a pretty useful feature where a rich delegate protocol could have many requirements but a type would not be forced to implement them all but it was never implemented in Swift. It wouldn't be difficult to implement (a null entry in the witness table) and the syntax exists where you could have to add a ? after the member name when calling through the protocol in case it wasn't implemented. It seems such an obvious omission there must have been a very good reason for it, can someone fill me in on what it was?

3 Likes

Here is the original proposal: swift-evolution/proposals/0070-optional-requirements.md at main · apple/swift-evolution · GitHub
There are explanations in it

4 Likes

Thanks, the argument seemed to be a combination of "let's keep it simple" and "you can provide a default with protocol extensions anyway" but there are things an optional method on a protocol can do i.e. have access to the conforming object that protocol extensions can't do. Anyway seems like the ship sailed on this one a long time ago.

1 Like

Yes, you are right. I would also add that delegate pattern is overused in Obj-C and UIKit particularly. It is quite uncommon in app codebases. Nevertheless, large protocols like UITableViewDelegate & UITableViewDataSource can be separated in to several small, something like this:

protocol UITableViewDelegate: AnyObject {}

protocol UITableViewDelegateCellDisplaying: UITableViewDelegate {
  func tableView(_ tableView: TableView, willDisplayCell: Cell, at indexPath: IndexPath)
  func tableView(_ tableView: TableView, didEndDisplayingCell: Cell, at indexPath: IndexPath)
}

protocol UITableViewDelegateHeaderDisplaying: UITableViewDelegate {
  func tableView(_ tableView: TableView, willDisplayHeader: View, at indexPath: IndexPath)
  func tableView(_ tableView: TableView, didEndDisplayingHeader: View, at indexPath: IndexPath)
}

// ... etc

class TableView {
  private weak var delegate: UITableViewDelegate?

  func foo() {
    if let delegate = delegate as? UITableViewDelegateCellDisplaying {
      delegate.tableView(_ tableView: self, willDisplayCell: cell, at indexPath: indexPath)
    }

    if let delegate = delegate as? UITableViewDelegateHeaderDisplaying {
      delegate.tableView(_ tableView: self, willDisplayHeader: headerView, at indexPath: indexPath)
    }
  }
}

In reality, something like this is rarely needed, and in those cases we can use several techniques. But these are still old patterns.

As we can see, modern Swift development provide completely different solutions.
As an alternative to Delegate pattern Rx was used, then Combine appeared and for now we have modern concurrency with async / await, AsyncChannels and son on.
SwiftUI is another example where Delegate pattern is not needed by design.
So instead of using old patterns we can use lots of new that were previously unavailable. Some time is needed to become thinking in a new way, though.

To answer the title question - yes I do miss optional requirements (even though the name is a funny oxymoron; we could call them "optional members"). I also miss pure virtual methods of C++ every time I have to put fatalError("must override") as a poor man's substitute.

2 Likes

I miss them too, although admittedly I don't have a need for them all that often.

I've particularly never liked the "just split your protocol up into multiple pieces" alternative, as that can get messy and pathological (e.g. often devolving to a unique protocol for every method).

Swift would need substantial changes to support them, though - at least, beyond the existing support through the Objective-C bridging. Given that Swift method calls aren't dynamically dispatched in the way that Objective-C messages are, and Swift doesn't have the introspection capabilities of Objective-C.

5 Likes

For the record, the thing optional implementations have (at least for methods) that default implementations don't is checking whether a type has provided an explicit implementation. Which you can mimic and make more flexible with default implementations:

// Before
protocol MyDelegate: AnyObject {
  optional func myView(_ myView: MyView, processInput input: String) -> String
}

// After
protocol MyDelegate: AnyObject {
  func myView(_ myView: MyView, processInput input: String) -> String?
}

extension MyDelegate {
  func myView(_ myView: MyView, processInput input: String) -> String? { nil }
}

"But that's not the same thing at all!" Yes, it's more flexible, even! It lets a delegate log the input or whatever, but defer to the original object for processing by returning nil anyway.

"But the return type was already optional!" You have the option (heh) to nest optionals, or use a bespoke enum for less confusion, but consider whether you really need to distinguish nil from "unimplemented". Sometimes you do! But usually it was going to be your default anyway.

"But I don't have a return value!" Use Bool, or Void? if you want behavior like @objc optional.

"But I wanted to use a property!" Swift already kind of bungles imported ObjC optional properties, since there isn't a separate "call" step when you use them. If your property has a setter, I don't really have a good answer for you. (But part of the reason optional properties are bungled is because we didn't have a good answer for those either.)

"But I wanted to save the cost of a call!" Eh, I guess? That's probably not an issue in practice in most cases. Testing whether a requirement is missing is about third of the cost of calling anyway (extremely rough numbers, depends somewhat on the argument types, etc).

"But I really do want to distinguish whether the default implementation is being used." I've seen a very small number of legitimate use cases for this, the main one being changing the default behavior without affecting those who have manually customized. That's an iffy thing to do in general, but it's something that could be added to the language without calling it optional and affecting the main path (as a run-time reflective capability, basically).

"But that's really verbose!" Yeah, this one I'll agree with. Way way back, we talked about being able to write unconstrained default implementations in the main protocol declaration; that would help. Macros could potentially also help, though I hesitate to lean on them due to the compilation time concerns.

"But that's not satisfying!" Entirely subjective—your opinion is as valid as mine—so I can't argue :person_shrugging:

9 Likes

On a philosophical level, it was always a bit absurd: if a requirement is optional... then it wasn't a requirement! Ignoring the oxymoron in the name, it was useful on a pragmatic level.

A protocol with n optional requirements basically replaces n smaller protocols and 2^n composite types (A & B ..., assuming every permutation of n options is possible).

A common replacement I see for these is having a struct with n optional callbacks. I'm not sure if that's better or worse.

7 Likes

I don't think introspection is required, as optional requirements could be implemented (ideally behind the scenes!) via optional closures:

protocol P {
    optional func foo(param: Int) // ↔ let foo_param: ((Int) -> Void)?
}
var p: P = ...
p.foo?(param: 1)     // ↔ p.foo_param?(1)
if let foo = p.foo { // ↔ if let foo = p.foo_param {

Loos like Swift team really didn't want to have optional requirements...

1 Like

Ah, I didn't suggest manual optional closures cause they don't work for mutating requirements, and cause you have to give up good names. Obviously there are lots of ways the implementation can work.

4 Likes

I finally understand what's happening here. If you provide a default implementation for a method in a protocol extension, you don't need to "satisfy the requirement" in a conforming type and Swift puts a pointer to the default method in the conforming type's "witness table" for when it is accessed through an existential. If you do provide a method in the conforming type it will take its place in the witness table and be called instead. Amounts to the same in a roundabout way I guess.

1 Like

Illustrating this particular bullet point. It suggests having this:

enum Implementation<Wrapped> {
    case `default`(Wrapped), customized(Wrapped)
}
protocol P {
    func foo() -> Implementation<String?>
}
extension P {
    func foo() -> Implementation<String?> {
        print("default implementation")
        return .default(nil)
    }
}
struct S1: P {}
struct S2: P {
    func foo() -> Implementation<String?> {
        print("non default implementation")
        return .customized(nil)
    }
}
struct S3: P {
    func foo() -> Implementation<String?> {
        print("non default implementation")
        return .customized("non null")
    }
}

and its usage:

// usage1 – test if the requirement is implemented without calling it
//    - not possible
// usage2 – test if the requirement is implemented with calling it
let p: P = ...
switch p.foo() {
    case let .default(value):
        print("not implementated")
    case let .customized(value):
        print("use \(value)")
}

instead of this:

protocol P {
    optional func foo() -> String?
}
struct S1: P {}
struct S2: P {
    func foo() -> String? {
        print("non default implementation")
        return nil
    }
}
struct S3: P {
    func foo() -> String? {
        print("non default implementation")
        return "non null"
    }
}

and it's usage:

// usage1 – test if the requirement is implemented without calling it
let p: P = ...
if p.foo != nil { ... }

// usage2 – test if the requirement is implemented with calling it
let p: P = ...
if let foo = p.foo() {
    print("use: \(value)")
} else {
    print("not implemented")
}

I can't say I am satisfied with this workaround because it looks clumsy. By a way of analogy – we don't return some WrapperType<ActualResult> from async functions like in C#.

Another workaround would be provide a second sidekick requirement:

protocol P {
    func foo()
    var isFooCustomized: Bool { get }
}
extension P {
    func foo() {}
    var isFooCustomized: Bool { false }
}
struct S1: P {}
struct S2: P {
    func foo() {}
    var isFooCustomized: Bool { true }
}

to get to the usage:

// usage1 – test if the requirement is implemented without calling it
let p: P = ...
if p.isFooCustomized { ... }

// usage2 – test if the requirement is implemented with calling it
let p: P = ...
if p.isFooCustomized {
    print("use: \(p.foo())")
} else {
    print("not implemented")
}

Which is also quite heavy and error prone (added foo implementation but forgot to change isFooCustomized or vice versa, forgot to check isFooCustomized, checked the wrong isBarCustomized to determine if foo is implemented and so on).

If you want no compromises on capabilities, but can compromise on verbosity, I'll link to my previous post on this topic:

Also keep in mind that all of this is only needed when you want to distinguish customization. Which really is uncommon. Pretty much all of those delegate methods have reasonable defaults—“do nothing”, “deny all drags”, “allow all selections”, etc.

4 Likes

I’m sure you can mark the protocol declaration with @objc then mark the methods of your choosing with @optional.

Yep, sometimes you could.

struct S { var some = 1 }
enum E { case some, other }
class C { var some = 1 }

@objc protocol P {
    @objc mutating func foo()     // 🛑
    @objc func foo(v: inout Int)  // 🛑
    @objc func foo(v: S)          // 🛑
    @objc func foo(v: E)          // 🛑
    @objc func foo(v: C)          // 🛑
}
1 Like

I should be clear that y’all can certainly feel

about these patterns to get @optional-like behavior. But given that there are reasons to want default implementations in the language, also having optional requirements would have been mostly redundant, and I don’t want you to be stuck thinking “this can’t be done in Swift” when probably it can still solve whatever made you want optional implementations.

2 Likes

You could use the same line of reasoning to not provide many things in Swift.
e.g. instead of having default argument values we could use either overloading or (when it would lead into a combinatorial explosion) a parameter of an "enum DefaultArgument<T> { case value(T), case default(T=Y) }" type. Or instead of having built-in Optional type and a bespoke "if let x = y" syntax we could use a user provided "enum MyOptional<T> { case none, some(T) }" type and a full-blown "if case let .some(x) = y" syntax.

1 Like