How to compose multiple type-erased modules in swift?

457 views Asked by At

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.

2

There are 2 answers

2
Hamish On BEST ANSWER

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
}
4
Nandin Borjigin On

@Hamish pointed out a good solution.

After posted this question, I did some tests and also found out a workaround

class PipelineImpl<T>: Pipeline {
    required init<S: Source, P: Procedure>(source: S, procedure: P) where S.DataType == T, P.DataType == T {
        // This initializer does the real jobs.
        self.source = AnySource(source)
        self.procedure = AnyProcedure(procedure)
    }
    required convenience init<S: Source, P: Procedure>(source: S, procedure: P) where S.DataType == P.DataType {
        // This initializer confirms to the protocol and forwards the work to the initializer above
        self.init(source: source, procedure: procedure)
    }
}