Search code examples
swiftgenericstype-erasureassociated-types

How to compose multiple type-erased modules in swift?


Swift is amazing but not mature yet, so there are some compiler restrictions and among them is generic protocols. Generic protocols can't be used as a regular type annotation due to type-safety considerations. I found a workaround in a post by Hector Matos. Generic Protocols & Their Shortcomings

The main idea is use type erasure to convert a generic protocol into a generic class, and it's cool. But I was stuck when applying this tech to more complex scenarios.

Assume there is an abstract Source, which produces data, and an abstract Procedure, which processes that data, and a pipeline, which combines a source and a procedure whose data types are matched.

protocol Source {
    associatedtype DataType
    func newData() -> DataType
}
protocol Procedure {
    associatedtype DataType
    func process(data: DataType)
}
protocol Pipeline {
    func exec() // The execution may differ
}

And the I want the client code to be simple as:

class Client {
    private let pipeline: Pipeline
    init(pipeline: Pipeline) {
        self.pipeline = pipeline
    }
    func work() {
        pipeline.exec()
    }
}

// Assume there are two implementation of Source and Procedure,
// SourceImpl and ProcedureImpl, whose DataType are identical.
// And also an implementation of Pipeline -- PipelineImpl

Client(pipeline: PipelineImpl(source: SourceImpl(), procedure: ProcedureImpl())).work()

Implementing Source and Procedure is simple, since they are at the bottom of the dependecy:

class SourceImpl: Source {
    func newData() -> Int { return 1 }
}

class ProcedureImpl: Procedure {
    func process(data: Int) { print(data) }
}

The PITA appears when implementing Pipeline

// A concrete Pipeline need to store the Source and Procedure, and they're generic protocols, so a type erasure is needed
class AnySource<T>: Source {
    private let _newData: () -> T
    required init<S: Source>(_ source: S) where S.DataType == T {
        _newData = source.newData
    } 
    func newData() -> T { return _newData() }
}
class AnyProcedure<T>: Procedure {
    // Similar to above.
}

class PipelineImpl<T>: Pipeline {
    private let source: AnySource<T>
    private let procedure: AnySource<T>
    required init<S: Source, P: Procedure>(source: S, procedure: P) where S.DataType == T, P.DataType == T {
         self.source = AnySource(source)
         self.procedure = AnyProcedure(procedure)
    }
    func exec() {
         procedure.process(data: source.newData())
    }
}

Ugh, Actually this one works! Am I kidding you? No.

I am not satisfied with this one, because the initializer of PipelineImpl is quite generic, so I want it to be in the protocol (Am I wrong with this obsession?). And this leads to two end:

  1. The protocol Pipeline will be generic. The initializer contains a where clause which refers to placeholder T, so I need to move the placeholder T into protocol as an associated type. Then the protocol turns into a generic one, which means I can't use it directly in my client code -- may need another type erasure.

    Although I can bear the troublesome of writing another type erasure for the Pipeline protocol, I don't know how to deal with the initializer function because the AnyPipeline<T> class must implement the initializer regarding to the protocol but it's only a thunk class actually, which shouldn't implement any initializer itself.

  2. Keep the protocol Pipeline non-generic. With writing the initializer like

    init<S: Source, P: Procedure>(source: S, procedure: P) 
    where S.DataType == P.DataType
    

    I can prevent the protocol being generic. This means the protocol only states that "Source and Procedure must have same DataType and I don't care what it is". This makes more sense but I failed to implement a concrete class confirming this protocol

    class PipelineImpl<T>: Protocol {
        private let source: AnySource<T>
        private let procedure: AnyProcedure<T>
        init<S: Source, P: Procedure>(source: S, procedure: P) 
        where S.DataType == P.DataType {
            self.source = AnySource(source) // doesn't compile, because S is nothing to do with T
            self.procedure = AnyProcedure(procedure) // doesn't compile as well
        }
        // If I add S.DataType == T, P.DataType == T condition to where clasue, 
        // the initializer won't confirm to the protocol and the compiler will complain as well
    }
    

So, how could I deal with this?

Thanks for reading allll this.


Solution

  • I think you're over-complicating this somewhat (unless I'm missing something) – your PipelineImpl doesn't appear to be anything more than a wrapper for a function that takes data from a Source and passes it to a Procedure.

    As such, it doesn't need to be generic, as the outside world doesn't need to know about the type of data being passed – it just needs to know that it can call exec(). As a consequence, this also means that (for now at least) you don't need the AnySource or AnyProcedure type erasures.

    A simple implementation of this wrapper would be:

    struct PipelineImpl : Pipeline {
    
        private let _exec : () -> Void
    
        init<S : Source, P : Procedure>(source: S, procedure: P) where S.DataType == P.DataType {
            _exec = { procedure.process(data: source.newData()) }
        }
    
        func exec() {
            // do pre-work here (if any)
            _exec()
            // do post-work here (if any)
        }
    }
    

    This leaves you free to add the initialiser to your Pipeline protocol, as it need not concern itself with what the actual DataType is – only that the source and procedure must have the same DataType:

    protocol Pipeline {
        init<S : Source, P : Procedure>(source: S, procedure: P) where S.DataType == P.DataType
        func exec() // The execution may differ
    }