Hi, in my app I use types to represent a product's variations. The product has a feature. Each variation of the product has a variation of that feature. Also, there is a variation of the product which doesn't have that feature. My goal is to define a common interface to access all product variations by using protocol.
I used to do it this way (without using associated type). It works fine.
// Approach 1: protocol without associated type
protocol FeatureProtocol {
var id: UUID { get }
}
protocol ProductProtocol {
var feature: FeatureProtocol? { get }
}
// Use case 1: variation A of the product
struct FeatureA: FeatureProtocol {
var id: UUID
}
struct ProductA: ProductProtocol {
var featureImpl: FeatureA
var feature: FeatureProtocol? { featureImpl }
}
// Use case 2: variation B of the product. It doesn't have the feature.
struct ProductB: ProductProtocol {
var feature: FeatureProtocol? { nil }
}
The approach works fine. The only minor issue is the boilerplate code in ProductA. And as the relation becomes more complex, the boilerplate code accumulate. So I recently started modifying it to use protocol with associated type. But I ran into an issue when dealing with product variation with no feature. See example code below:
// Approach 2: protocol with associated type
protocol FeatureProtocol {
associatedtype IDType: Equatable
var id: IDType { get }
}
protocol ProductProtocol {
associatedtype FeatureType: FeatureProtocol
var feature: FeatureType? { get }
}
// Use case 1: variation A of the product
protocol FeatureAProtocol: FeatureProtocol where IDType: Hashable {
}
protocol ProductAProtocol: ProductProtocol where FeatureType: FeatureAProtocol {
}
struct FeatureA: FeatureAProtocol {
var id: UUID
}
struct ProductA: ProductAProtocol {
var feature: FeatureA?
}
// Use case 2: variation B of the product. It doesn't have the feature.
// We don't really use it. But we have to define it.
struct FeatureB: FeatureProtocol {
var id: UUID
}
struct ProductB: ProductProtocol {
var feature: FeatureB? { nil }
}
The issue is with FeatureB in above code. I have to define it, although I don't use it at all. I somehow understand why it's so. Optional in Swift is designed for representing an optional value, not an optional type (e.g. a property which may or may not exist in a type). The later is what I'd like to express in my design.
I have been thinking about this in the past two days. I can't think out any way to get rid of FeatureB. It would be much cleaner if I could use something like Never in this case, rather than have to define a dummy type. But apparently it doesn't work. I also considered introducing an additional function API to indicate if the product has the feature or not, but wasn't able to find a solution. My goal is actually simple: I want a general interface to access all product variations (including the one which doesn't have the feature) and I'd like to do it by using protocol. I wonder if anyone has better suggestions? Thanks in advance.
BTW, this is the first time I use protocol with associated types in my project. I thought using it would be much simpler than my original approach (protocol without associated type). But I start to doubt it. Just look at the above simple example, the code in approach 1 seems simpler and easier to understand than that in approach 2. The types in my app has very complex relation. I have gone a long way to implement it using approach 1 and it works fine. I doubt if it's worth the effort to rewrite it using approach 2? What benefit does approach 2 bring? While it does help to get rid of the boilerplate code, its code seems more complex to write and to understand. I wonder how people in this forum with practical experience think about it? Thanks for any opinion.
You're not taking advantage of types if you utilize Optional for this. Seems to me like you should use an intermediate protocol that provides compile-time clarity.
protocol Product { }
protocol ProductWithFeature: Product {
associatedtype Feature: FeatureProtocol
var feature: Feature { get }
}
protocol ProductAProtocol: ProductWithFeature where Feature: FeatureAProtocol {
struct ProductB: Product { }
Alternatively, if you do like the Never approach…
protocol Product {
associatedtype Feature: FeatureProtocol
var feature: Feature { get }
}
extension Never: FeatureProtocol { }
extension Product where Feature == Never {
var feature: Feature { fatalError() }
}
struct ProductB: Product { }
Thanks! I also thought I probably used Optional for a wrong scenario, but I didn't figure out what the right solution should be.
I haven't looked at the the Never approach because you seem to recommend the intermediate protocol approach. Yes, the intermediate protocol approach is more clear indeed. I have a question though. Suppose I get a value which conforms to Product protocol, how can I check if that value conforms to ProductWithFeature protocol?
func test<T: Product>(_ product: T) {
// This doesn't compile. Q: how to check the conformance?
if let product = product as? ProductWithFeatureProtocol {
print(product.feature.id)
}
}
My goal is to have a common api to process all products.
There might be copy and paste error in your code. It doesn't compile. But I see your point. It's to extend Never to conform to the protocol. Below is a version that works:
protocol FeatureProtocol {
associatedtype IDType: Equatable
var id: IDType { get }
}
protocol ProductProtocol {
associatedtype Feature: FeatureProtocol
var feature: Feature? { get }
}
extension Never: FeatureProtocol {
var id: UUID { fatalError() }
}
struct ProductB: ProductProtocol {
var feature: Never? = nil
}
That said, I like your first approach much more if I can get it working. Will continue looking into it.
To answer my own question, below is a solution to the above specific example. Note it avoids conformance check at runtime.
func test<T: ProductProtocol>(_ product: T) {
print("The product has no feature")
}
func test<T: ProductWithFeatureProtocol>(_ product: T) {
print("The product has feature: \(product.feature.id)")
}
let productA = ProductA(feature: FeatureA(id: UUID()))
let productB = ProductB()
test(productA)
test(productB)
But what I really wanted to figure out is if there is a way to check a value conform to a protocol with associated type, because there are scenarios where this approach is infeasible. Below is an complete example:
protocol ProductProtocol {
}
protocol FeatureProtocol {
associatedtype IDType: Equatable
var id: IDType { get }
}
protocol ProductWithFeatureProtocol: ProductProtocol {
associatedtype FeatureType: FeatureProtocol
var feature: FeatureType { get }
}
// Product A: it has a variation of the feature.
protocol FeatureAProtocol: FeatureProtocol where IDType: Hashable {
}
protocol ProductAProtocol: ProductWithFeatureProtocol where FeatureType: FeatureAProtocol {
}
struct FeatureA: FeatureAProtocol, Codable {
var id: UUID
}
struct ProductA: ProductAProtocol, Codable {
var feature: FeatureA
}
// Product B: it doesn't have the feature.
struct ProductB: ProductProtocol, Codable {
}
// API
func test<T: ProductProtocol>(_ product: T) {
print("The product has no feature")
}
func test<T: ProductWithFeatureProtocol>(_ product: T) {
print("The product has feature: \(product.feature.id)")
}
let productA = ProductA(feature: FeatureA(id: UUID()))
let productB = ProductB()
// This works.
test(productA)
test(productB)
// Setup: save productA and productB in an array of Codable. As a result, the compiler lost their original types.
var products: [Codable] = []
products.append(productA)
products.append(productB)
// Error: this doesn't compile.
for product in products {
test(product)
}
I think out two ways to get the above example working. Neither is ideal and both are based on the same idea: trying to keep (or restore) variable's original type. One approach is to cast the item retrieved from the array to its original type by checking it against all product variation types defined in the app. Another approach is to not mix up types when saving them in array, so it will be easier to cast the items to their original types. Below is an example of the second approach:
var productAs: [Codable] = []
productAs.append(productA)
var productBs: [Codable] = []
productBs.append(productB)
for product in productAs {
let product = product as! ProductA
test(product)
}
The approach also has a limitation: to access a value through a protocol which it conforms to and which has associated type, it has to be done through generic function or generic type. That means, if one just wants to access productA.feature.id in above example, he can't use the dot syntax, instead he has to introduce a generic function just for this purpose. That seems very inconvenient to me.
Regarding the way to check if a value conform to a protocol with associated type, I find related discussions here and here. I didn't go through all the discussion, but it's obvious that Swift doesn't support it.