如何在Swift中组合多个类型擦除模块?

3
Swift很棒,但并不成熟,因此存在一些编译器限制,其中之一是通用协议。由于类型安全的考虑,通用协议不能用作常规类型注释。我在Hector Matos的文章中找到了一个解决方法。Generic Protocols & Their Shortcomings 主要思路是使用类型擦除将通用协议转换为通用类,这很酷。但是当应用该技术到更复杂的场景时会遇到困难。
假设存在一个抽象的Source,它生成数据,以及一个抽象的Procedure,它处理这些数据,并且有一个管道,它组合了一个源和一个过程,其数据类型匹配。
protocol Source {
    associatedtype DataType
    func newData() -> DataType
}
protocol Procedure {
    associatedtype DataType
    func process(data: DataType)
}
protocol Pipeline {
    func exec() // The execution may differ
}

我希望客户端代码能够简单易懂:

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()

实现 Source 和 Procedure 很简单,因为它们处于依赖关系的底部:

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

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

在实现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())
    }
}

嗨,实际上这个是可行的!我在开玩笑吗?没有。

我对这个不太满意,因为PipelineImplinitializer非常通用,所以我希望它在协议中(这种追求有问题吗?)。这导致了两个结果:

  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
    }
    
所以,我该怎么处理这个问题呢?谢谢阅读所有这些内容。
2个回答

1
我认为你可能过于复杂化了这个问题(除非我漏掉了什么)- 你的 PipelineImpl 看起来只是一个包装器,用于从 Source 获取数据并将其传递给 Procedure 的函数。
因此,它不需要是通用的,因为外部世界不需要知道传递的数据类型 - 它只需要知道可以调用 exec()。因此,这也意味着(至少目前)你不需要使用 AnySourceAnyProcedure 类型擦除。
这个包装器的简单实现如下:
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)
    }
}

这样您就可以将初始化器添加到您的Pipeline协议中,因为它不需要关心实际的DataType是什么,只需要源和过程具有相同的DataType即可。
protocol Pipeline {
    init<S : Source, P : Procedure>(source: S, procedure: P) where S.DataType == P.DataType
    func exec() // The execution may differ
}

好的观点,但在不同的“exec”函数中可能存在一些不同的前后工作要做。然而,在这种情况下,@Hamish的方法仍然有效,我认为。谢谢! - Nandin Borjigin
@NandiinBao 哦,我明白了 - 我已经编辑了我的答案来解决这个问题。很高兴能帮到你 :) - Hamish

0

@Hamish提出了一个好的解决方案。

在发布这个问题后,我进行了一些测试,也找到了一个解决方法。

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)
    }
}

你的第二个初始化器实际上会递归调用自己,因为编译器无法保证 DataType == T - 因此 self.init 只能引用自身。 - Hamish
我在Xcode playground中进行了测试,调用init(source: procedure:)实际上会调用第一个初始化程序,而第二个初始化程序从未被调用。第二个初始化程序仅用于确认协议。然而,通过这样做,我可能失去了在协议中包含初始化程序签名的意义... - Nandin Borjigin
此外,你是对的。如果我将第一个初始化程序设置为私有,并调用类似 PipelineImpl<Int>(source: source, procedure: procedure) 的东西,它会陷入死循环。我没有意识到这一点。 - Nandin Borjigin
您可以通过传递源和过程来调用第二个初始化程序,其中“DataType”匹配,但它们不匹配“T”(例如,“SourceImpl”和“ProcedureImpl”实例,其中“DataType”为“Int”,传递到“PipelineImpl <String>”初始化程序中)。 此外,正如您所提到的,将第二个初始化程序作为协议要求也会导致问题-对于具有“<T:Pipeline>”通用占位符的函数,调用“T(source:procedure :)”将始终递归,当“T”是“PipelineImpl”时。 - Hamish

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接