A design question: alternative design to avoid "type conforming to the same protocol multiple times" issue

Hi, I ran into a design issue with regarding to Swift not allowing Array of different Element types to conform to the same protocol multiple times. I wonder how to make an alternative design. Thanks for any help.

My scenario: my app has two entities Foo and Bar. They are saved in separate arrays. I extend [Foo] and [Bar] to add methods to create/modify/delete a single instance. Because Foo and Bar have different details, they don't share code in those CRUD methods.

Below are the initial code, which contain only APIs working on single instance. The code are straightforward.

struct Foo {
    var id: UUID
    var x: Int
}

struct Bar {
    var id: UUID
    var y: String
}

typealias FooStorage = [Foo]
typealias BarStorage = [Bar]

extension FooStorage {
    mutating func modify(id: UUID, newValue: Int) {
        guard let index = firstIndex(where: { $0.id == id }) else { return }
        self[index].x = newValue
    }
}

extension BarStorage {
    mutating func modify(id: UUID, newValue: String) {
        guard let index = firstIndex(where: { $0.id == id }) else { return }
        self[index].y = newValue
    }
}

The question is about how to implement APIs that process multiple instances in batch. At first I implement them for Foo and Bar separately, but I noticed I did a lot of copy and paste. So I tried to share the multiple instances API code between Foo and Bar. Below is how I implement it (note I modified the single instance api code a bit). The code almost works, excep that it fails to compile. See the error message at the end of the code.

struct Foo {
    var id: UUID
    var x: Int
}

struct Bar {
    var id: UUID
    var y: String
}

typealias FooStorage = [Foo]
typealias BarStorage = [Bar]

extension FooStorage {
    mutating func modify(id: UUID, newValue: Int) {
        guard let index = getIndex(id: id) else { return }
        _modify(index: index, newValue: newValue)
    }

    mutating func _modify(index: Index, newValue: Int) {
        self[index].x = newValue
    }

    func getIndex(id: UUID) -> Index? {
        firstIndex(where: { $0.id == id })
    }
}

extension BarStorage {
    mutating func modify(id: UUID, newValue: String) {
        guard let index = getIndex(id: id) else { return }
        _modify(index: index, newValue: newValue)
    }

    mutating func _modify(index: Index, newValue: String) {
        self[index].y = newValue
    }

    func getIndex(id: UUID) -> Index? {
        firstIndex(where: { $0.id == id })
    }
}

protocol BatchProcessing: Collection where Index: Strideable, Index.Stride: SignedInteger {
    associatedtype Value

    // This is the single instance crud api (its internal version). It's implemented manually. It will be used to implement the batch processing api.
    mutating func _modify(index: Index, newValue: Value)

    // This is batch processing api. It's to be implemented in the protocol extension. That's how we share the code between Foo and Bar.
    mutating func batchModify(firstID: UUID, newValue: Value)

    // helper util
    func getIndex(id: UUID) -> Index?
}

extension BatchProcessing {
    mutating func batchModify(firstID: UUID, newValue: Value) {
        guard var index = getIndex(id: firstID) else { return }
        while index < endIndex {
            _modify(index: index, newValue: newValue)
            index = self.index(after: index)
        }
    }
}

extension FooStorage: BatchProcessing {
    typealias Value = Int
}

extension BarStorage: BatchProcessing {
    typealias Value = String
}

// Error: Conflicting conformance of 'Array<Element>' to protocol 'BatchProcessing'; there cannot be more than one conformance, even with different conditional bounds

My first thought was to change the batchModify() func to a generic function in Collection protocol, but then I realized it wouldn't work, because it depends on _modify() and, to express the dependence, I need to use protocol.

It seems the only option is to define FooStroage and BarStorage as two separate structs wrapping [Foo] and [Bar], respectively. I think that should get rid of the compiler error, but on the other hand, introducing the wrapper types will make the code unnecessarily verbose.

Does anyone have any suggestion? Thanks.

I find another approach. It unfortunately doesn't compile either, but it's due to a different issue (it violates the law of exclusivity). The approach is in line of my original thought: changing the batchModify() func to a generic function in Collection protocol. To addresse the dependence issue, it takes closure params.

struct Foo {
    var id: UUID
    var x: Int
}

struct Bar {
    var id: UUID
    var y: String
}

typealias FooStorage = [Foo]
typealias BarStorage = [Bar]

extension FooStorage {
    mutating func modify(id: UUID, newValue: Int) {
        guard let index = getIndex(id: id) else { return }
        _modify(index: index, newValue: newValue)
    }

    mutating func _modify(index: Index, newValue: Int) {
        self[index].x = newValue
    }

    func getIndex(id: UUID) -> Index? {
        firstIndex(where: { $0.id == id })
    }
}

extension BarStorage {
    mutating func modify(id: UUID, newValue: String) {
        guard let index = getIndex(id: id) else { return }
        _modify(index: index, newValue: newValue)
    }

    mutating func _modify(index: Index, newValue: String) {
        self[index].y = newValue
    }

    func getIndex(id: UUID) -> Index? {
        firstIndex(where: { $0.id == id })
    }
}

extension Collection where Index: Strideable, Index.Stride: SignedInteger {
    // The getIndex closure can be removed by requiring the Element to conform to a protocol.
    mutating func batchModify<Value>(firstID: UUID, newValue: Value, getIndex: (UUID) -> Index?, modify: (Index, Value) -> Void) {
        guard var index = getIndex(firstID) else { return }
        while index < endIndex {
            modify(index, newValue)
            index = self.index(after: index)
        }
    }
}

extension FooStorage {
    mutating func batchModify(firstID: UUID, newValue: Int) {
        batchModify(firstID: firstID,
                    newValue: newValue,
                    getIndex: getIndex,
                    modify: _modify) // Error: Escaping autoclosure captures 'inout' parameter 'self'
    }
}

extension BarStorage {
    mutating func batchModify(firstID: UUID, newValue: String) {
        batchModify(firstID: firstID,
                    newValue: newValue,
                    getIndex: getIndex,
                    modify: _modify) // Error: Escaping autoclosure captures 'inout' parameter 'self'
    }
}

It turns out I find a very elegant solution. The key point: to avoid the compiler error I shouldn't have separate single instance APIs for [Foo] and [Bar]. To have a single set of APIs for both, I should make Foo and Bar conforms to the same protocol (that's Entity in the code), Note that doesn't mean I have to share code among Foo and Bar's single instance API.

protocol Entity {
    associatedtype Value
    
    var id: UUID { get }
    mutating func modify(newValue: Value)
}

struct Foo: Entity {
    var id: UUID
    var x: Int
    
    mutating func modify(newValue: Int) {
        x = newValue
    }
}

struct Bar: Entity {
    var id: UUID
    var y: String
    
    mutating func modify(newValue: String) {
        y = newValue
    }
}

extension Array where Element: Entity {
    mutating func modify(id: UUID, newValue: Element.Value) {
        guard let index = getIndex(id: id) else { return }
        _modify(index: index, newValue: newValue)
    }

    mutating func _modify(index: Index, newValue: Element.Value) {
        self[index].modify(newValue: newValue)
    }
    
    func getIndex(id: UUID) -> Index? {
        firstIndex(where: { $0.id == id })
    }
    
    mutating func modifyBatch(firstID: UUID, newValue: Element.Value) {
        guard var index = getIndex(id: firstID) else { return }
        while index < endIndex {
            _modify(index: index, newValue: newValue)
            index = self.index(after: index)
        }
    }
}
2 Likes