Lee Kah Seng
Lee Kah Seng

Reputation: 183

How to refactor this Swift protocol with where clause?

I have a Savable protocol that has multiple versions of save() method based on the conformance of C1 and C2. And then there is another Downloadable protocol that conforms to Savable protocol that calls the Savable's save() method based on the conformance of C1 and C2.

As can be seen from the sample code below, the download() methods are all the same. Is there a way to simplify this code by removing the repetitive functions? Or should I use another totally different approach to get the same outcome?

PS: Both Savable and Downloadable need to be a protocol for some unmentioned technical reasons. Furthermore, C1 and C2 both have Self or associated type requirements, thus performing a type check is not possible.

// Constraint
protocol C1 { }
protocol C2 { }

// Savable
protocol Savable { }
extension Savable {
    func save() {
        print("Perform basic save")
    }
}

extension Savable where Self: C1 {
    func save() {
        print("Perform save C1")
    }
}

extension Savable where Self: C1 & C2 {
    func save() {
        print("Perform save C1 & C2")
    }
}

// Downloadable
protocol Downloadable: Savable { }
extension Downloadable {
    func download() {
        print("Perform download")
        save()
    }
}

extension Downloadable where Self: C1 {
    func download() {
        print("Perform download")
        save()
    }
}

extension Downloadable where Self: C1 & C2 {
    func download() {
        print("Perform download")
        save()
    }
}

// Usage
class MyClassC1: Downloadable, C1 { }
let obj1 = MyClassC1()
obj1.download()
// Output:
// Perform download
// Perform save C1

class MyClassC1C2: Downloadable, C1, C2 { }
let obj2 = MyClassC1C2()
obj2.download()
// Output:
// Perform download
// Perform save C1 & C2

Thanks in advance!

Upvotes: 4

Views: 135

Answers (1)

Rob Napier
Rob Napier

Reputation: 299345

This approach is very fragile. You're relying on compile-time checks to perform overloads, and that only works if the type is known at compile-time. This kind of thing should only be done when there are no functional differences between the versions of save() (for example, if the only difference is performance). Protocols are not subclasses.

As an example of the problem, consider:

func getit(what: Downloadable) {
    what.download()
}

getit(what: obj2) // => "Perform basic save", not "perform save C1 & C2"

Even if you add generics, it'll "fail" exactly the same way:

func getit<D: Downloadable>(what: D) { ... }

This isn't really a failure. All the compiler at compile time knows is that this is of type Downloadable, so it'll call the Downloadable implementation.

If you want runtime dispatch, you need to do the dispatch at runtime with as?. Get rid of all the where extensions and replace save with:

protocol Savable { }
extension Savable {
    func save() {
        if self is C1 & C2 {
            print("Perform save C1 & C2")
        } else if self is C1 {
            print("Perform save C1")
        } else {
            print("Perform basic save")
        }
    }
}

Note the order is important (testing C1 & C2 before testing C1). Deciding how this dispatches is up to you.

This unfortunately requires you to statically know all the types at compile-time and put them in one place. It is possible to fix this and register "savers" centrally, but it's a bit tricky and I don't particularly recommend this unless you really need it. Here's how you would do it, however.

// A Saver object that knows all the "rules". It maps conformances to functions
class Saver {
    static let shared = Saver()

    private var savers: [(predicate: (Any) -> Bool, save: (Any) -> ())] = []

    func addSaver<T>(of: T.Type, save: @escaping (T) -> ()) {
        savers.append((predicate: { $0 is T }, save: { save($0 as! T) }))
    }

    func save(value: Any) {
        for (predicate, save) in savers {
            if predicate(value) {
                save(value)
                return
            }
        }
        fatalError("Couldn't save") // Or some default behavior or whatever
    }
}

// Now you configure the Saver. Order is important!
let saver = Saver.shared
saver.addSaver(of: (C1 & C2).self, save: { _ in print("Perform save C1 & C2") })
saver.addSaver(of: C1.self, save: { _ in print("Perform save C1") })
saver.addSaver(of: Savable.self, save: { _ in print("Perform basic save") })

// Savable
protocol Savable { }
extension Savable {
    func save() {
        // And use it here
        Saver.shared.save(value: self)
    }
}

Using this, you may discover many little corner cases. Like what happens if someone wants to add another saver at some random point in the program (maybe in another module?) so you may need to reorder the tests, or what if two savers are equally "specific" so you don't know which one to prefer. Those are exactly the corner cases the compiler faces when dealing with something like this.

Upvotes: 1

Related Questions