Still confused about protocols, generics, and actual use

Related to the last question I asked, I'm trying to do an inventory system. And I would like to be able to use multiple datastores for it -- in-core (very useful for just debugging), Core Data, and Firestore being the three I've got right now.

Again, this is mostly from my head, not my actual (not-so-working, and, uh, clumsy as well as ugly) code. But I start out with something like:

protocol InventoryItem {
    associatedtype idType
    var id: idType { get }
    var description: String { get set }
}
protocol InventoryStorage: InventoryItem {
    associatedtype storageType
    associatedtype itemType: InventoryItem
    var kind: storageType { get }
    var location: String { get }
    var items: [itemType] { get set }
    var itemCount: Int { get set }
}
protocol BookStorage: InventoryStorage {
    var shelfCount: Int { get }
}
protocol RefrigeratedStorage: InventoryStorage {
    var isFreezer: Bool { get }
    var cubicFeet: Int { get }
}
class StorageArray<Kind: InventoryStorage> {
    var units = [Kind]()
    init(...) { ... }
    func addItem(_ item: InventoryItem) 
    ....
}
class Bookcase: InventoryStorage {
    ...
}
class Refrigerator: InventoryStorage {
    ...
}
class Freezer: InventoryStorage {
    ....
}
class AisleShelf: InventoryStorage {
    ...
}
class Store {
    var bookcases: StorageArray<Bookcase>()
    var refrigerators: StorageArray<Refrigerator>()
    var freezers: StorageArray<Freezer>()
    var shelves: StorageArray<AisleShelf>()

   ...
}
extension CoreDataInventoryItem : InventoryItem {
    var id: UUID {
        if self.cdID == nil {
            self.cdID = UUID()
        }
        return self.cdID!
    }
}
// And similarly for other core data types

and more along those lines. I'm using a protocol because, as I said, there are hopefully multiple datastores to hold this information, and I'd prefer to be able to decide at run time rather than build time :). (Also, InventoryStorage inherits from InventoryItem simply because they also have an id and a name; my ui code can then use the same views for some basic displays.)

I can get those basics to work, with in-core and Core Data (haven't done Firestore yet, but only because it requires a lot more setup). But then I can't figure out how to have both an in-core version and a Core Data version, and pass those along via @EnvironmentObject.

I ended up making the Store class a generic, a la

class Store<BookcaseType: BookStorage, RefrigeratorType: RefrigeratedStorage, ...> {
    var bookcases: [BookcaseType]()
    ...
}

thinking I would then have something like

.environmentObject(Store<Bookcase, ...>())

or

.environmentObject(Store<CDBookcase, ...>())

But that doesn't work, because @EnvironmentObject needs to know the actual type, and using generics means it's a different type. (I've got a really ugly version using nothing but inherited classes, but my prototype protocol version is much cleaner, easier to read, and maintainable. Except for the whole, doesn't work part, of course.)

Wow this is long and a bit rambly, I'm sorry.

You cannot achieve what you want with your current setup because the moment you introduce associated types that protocol is no longer a regular type and there will be no more is-a relationships between each concrete instance and the protocol it implements.
Assuming you have these definitions somewhere in your code:

enum AppStorageKind { case inMemory, coreData, fireStore }
struct BookItem: InventoryItem { }
class InMemStorage: InventoryStorage { typealias itemType = BookItem, typealias storageType  = AppStorageKind, .... }
class CoreDataStorage: InventoryStorage { typealias itemType = BookItem, typealias storageType  = AppStorageKind, .... }

you probably want to achieve something like this :

struct BookView: View {
  @EnvironmentObject storage: InventoryStorage<storageType = AppStorageKind, itemType = BookITem> 
}

But this is not possible in Swift. Mainly because InventoryStorage (having associated types) cannot be used to declare a type but rather only as a type constraint (and also it's not valid syntax).

If you want to keep the protocol declarations, maybe the solution will be a combination of InventoryStorage and a base class:

 class BaseBookStorage: InventoryStorage {
    typealias itemType = BookItem
    typealias storageType = AppStorageKind
   // ... empty implementations for methods
}

class CoreDataBookStorage: BaseBookStorage { //..implement } 
class InMemoryBookStorage: BaseBookStorage {//..implement } 

Then you could have

struct BookView: View {
  @EnvironmentObject storage: BaseBookStorage
}
1 Like

I am in fact trying out the multiple class approach -- the problem is when the types involved are too different. Core Data, for example, has its own classes, and my hope had been that I could have used protocols to extend those classes. But then it's an entirely different type, and so I have to have another class to hold it.

Now, I can embed the core data classes inside another class (and I've got an implementation that does that), but it's really ugly.

I recognize it may not in fact be possible to tie all these Swift concepts together the way I want. But it seems to me I really should be able to :slight_smile:. Which is why I keep trying. (I keep feeling like there's just some small step I need to add that will do it. So I try for a few days, and get frustrated. Does that make sense to anyone other than me?)

One note unrelated to your actual issue: It is convention to capitalize types in Swift, including associatedtype or typealias type names.

Your

associatedtype itemType = BookItem

would be written as

associatedtype ItemType = BookItem

when following Swift conventions.

I had wondered about that. I mean, I knew that types were always capitalized, but being an associated type it looked more like a local variable. Thanks :slight_smile:

1 Like

Can you post the full code you're working on?

I'd prefer not to, since it's very embarrassing, but I may have to. The current incarnation is only ~2k LOC, admittedly.

This is the main interface file (what would be the main .h file in ObjC).

//
//  Pantry.swift
//  PantryOrganizer
//
//  Created by Sean Eric Fagan on 8/26/20.
//  Copyright © 2020 Kithrup Enterprises, Ltd. All rights reserved.
//

import Foundation

/*
 * This is where we'll define errors, protocols, and some other
 * common aspects for the entire project.
 */

enum PantryError : Error {
    case objectNotFound
    case duplicateObject
    case unknownType
}
/*
 * Every pantry object has a name and an ID.
 * In the case of types and locations, that's *all*
 * they have.
 */
enum PantryObjectType {
    case kind
    case location
    case storageUnit
    case item
    // This is just for cases where a placeholder is needed
    case placeholder 
}

protocol PantryObject: ObservableObject, Identifiable, CustomStringConvertible, Hashable {
//    init(_ name: String, extras: AnyObject...)
    associatedtype idType: Hashable
    var id: idType { get }
    var name: String { get set }
    var description: String { get }
    static var objectName: String { get }
    static var objectType: PantryObjectType { get }
    var objectType: PantryObjectType { get }
}

extension PantryObject {
    var objectType: PantryObjectType { return Self.objectType }
    var description: String {
        return "{ id: \(self.id), name: \(self.name) }"
    }
    func hash(into hasher: inout Hasher) {
        hasher.combine(self.id)
    }
    func isStorageUnit() -> Bool {
        return false
    }
    func isStorageItem() -> Bool {
        return false
    }
}

/*
 * A storage unit has the required name and id; it also has a type,
 * and an optional location.  Note that it does *not* have items;
 * all of the contents are in a single list in the organizer, and it
 * will manage them.  However, due to annoyances with Swift, generics,
 * and subclassing/extensions, we will only have the additional bits
 * for the storage object protocol.  This also means I can't provide
 * a default description method via a protocol extension, because
 * it depends on id and name.
 */

protocol PantryStorageObject: PantryObject {
    associatedtype typeType: PantryObject
    associatedtype locType: PantryObject
    associatedtype storageType: PantryStorageObject
    
    var pantryType: typeType { get }
    var pantryLocation: locType? { get }
    var description: String { get }
}

extension PantryStorageObject {
    var description : String {
        var retval : String = "StorageObject => { id: \(self.id), name: \(self.name), type: \(self.pantryType.name)"
        if self.pantryLocation != nil {
            retval += ", location: \(self.pantryLocation!.name)"
        }
        return retval + " }"
    }
    func isStorageUnit() -> Bool {
        return true
    }
    var objectType: PantryObjectType {
        return .storageUnit
    }
}

/*
 * A pantry item also has the requisite name and id; it
 * also has a pantry storage unit it lives in, and some
 * dates.  Also an item count, which will default to 1.
 */
protocol PantryItemObject: PantryObject {
    associatedtype storageType: PantryStorageObject
    var pantryStorageUnit: storageType { get }
    var addedDate: Date { get }
    var checkDate: Date? { get }
    var expireDate: Date? { get }
    var itemCount: Int { get set }
    
    func formateDate(_ : Date) -> String
}

extension PantryItemObject {
    var description: String {
        let formatter = DateFormatter()
        formatter.dateFormat = "EEE yyyy-MM-dd"
        var retval = "PantryItemObject => { id: \(self.id), name: \(self.name), unit: \(self.pantryStorageUnit.name), added: \(formatter.string(from: self.addedDate))"
        if self.checkDate != nil {
            retval += ", check: \(formatter.string(from: self.checkDate!))"
        }
        if self.expireDate != nil {
            retval += ", expires: \(formatter.string(from: self.expireDate!))"
        }
        retval += ", unit count: \(self.itemCount) }"
        return retval
    }
    func isStorageItem() -> Bool {
        return true
    }
    var objectType: PantryObjectType {
        return .item
    }

}

protocol PantryOrganizer {
    associatedtype ItemType: PantryItemObject
    associatedtype UnitType: PantryStorageObject
    associatedtype KindType: PantryObject
    associatedtype LocationType: PantryObject

    func createObject<T: PantryObject>(_ name: String, args: [String: Any]) -> T
    func createStorageUnit<T: PantryStorageObject>(_ name: String, kind: KindType, location: LocationType?) -> T
    
    func items() -> [ItemType]
    func items(in: UnitType) -> [ItemType]
    func addItem(_ item: ItemType) throws
    func findItem(itemID: ItemType.idType) throws -> ItemType
    
    func kinds() -> [KindType]
    func addKind(_ kind: KindType) throws
    func findKind(kindID: KindType.idType) throws -> KindType
    func removeKind(_ kind: KindType) throws
    
    func locations() -> [LocationType]
    func addLocation(_ location: LocationType) throws
    func findLocation(locationID: LocationType.idType) throws -> LocationType
    func removeLocation(_ location: LocationType) throws
    
    func units() -> [UnitType]
    func addStorageUnit(_ unit: UnitType) throws
    func findStorageUnit(unitID: UnitType.idType) throws -> UnitType
    func removeStorageUnit(_ unit: UnitType) throws
}

class BasePantryOrganizerObject<ItemType: PantryItemObject, UnitType: PantryStorageObject, KindType: PantryObject, LocationType: PantryObject> : ObservableObject, PantryOrganizer {
    var storageUnits = PantryObjectList<UnitType>()
    var storageLocations = PantryObjectList<LocationType>()
    var storageTypes = PantryObjectList<KindType>()
    var storageContents = PantryObjectList<ItemType>()

    func createPlaceholderObject(_ name: String) -> some PantryObject {
        return PantryPlaceholderObject("")
    }

    func createObject<T: PantryObject>(_ name: String, args: [String: Any]) -> T {
        fatalError("createObject must be overridden")
    }
    func createStorageUnit<T: PantryStorageObject>(_ name: String, kind: KindType, location: LocationType? = nil) -> T {
        fatalError("createStorageUnit must be overridden")
    }

    func items() -> [ItemType] {
        return self.storageContents.objectList
    }
    func items(in unit: UnitType) -> [ItemType] {
        return self.storageContents.find( { $0.pantryStorageUnit as! UnitType == unit })
    }
    func addItem(_ item: ItemType) throws {
        do {
            try self.storageContents.add(object: item)
        } catch {
            throw error
        }
    }
    func findItem(itemID: ItemType.idType) throws -> ItemType {
        do {
            return try self.storageContents.find(objectID: itemID)
        } catch {
            throw error
        }
    }

    func units() -> [UnitType] {
        return self.storageUnits.objectList
    }
    func addStorageUnit(_ unit: UnitType) throws {
        do {
            try self.storageUnits.add(object: unit)
        } catch {
            throw error
        }
    }
    func removeStorageUnit(_ unit: UnitType) throws {
        do {
            try self.storageUnits.remove(object: unit)
        } catch {
            throw error
        }
    }
    func findStorageUnit(unitID: UnitType.idType) throws -> UnitType {
        do {
            return try self.storageUnits.find(objectID: unitID)
        } catch {
            throw error
        }
    }
    
    func locations() -> [LocationType] {
        return self.storageLocations.objectList
    }
    func addLocation(_ location: LocationType) throws {
        do {
            try self.storageLocations.add(object: location)
        } catch {
            throw error
        }
    }
    func removeLocation(_ location: LocationType) throws {
        do {
            try self.storageLocations.remove(object: location)
        } catch {
            throw error
        }
    }
    func findLocation(locationID: LocationType.idType) throws -> LocationType {
        do {
            return try self.storageLocations.find(objectID: locationID)
        } catch {
            throw error
        }
    }
    
    func kinds() -> [KindType] {
        return self.storageTypes.objectList
    }
    func addKind(_ kind: KindType) throws {
        do {
            try self.storageTypes.add(object: kind)
        } catch {
            throw error
        }
    }
    func removeKind(_ kind: KindType) throws {
        do {
            try self.storageTypes.remove(object: kind)
        } catch {
            throw error
        }
    }
    func findKind(kindID: KindType.idType) throws -> KindType {
        do {
            return try self.storageTypes.find(objectID: kindID)
        } catch {
            throw error
        }
    }
}

class PantryPlaceholderObject: PantryObject {
    static var objectType: PantryObjectType {
        return .placeholder
    }
    var id = UUID()
    
    var name: String = ""
    
    static var objectName: String {
        return "Placeholder Object"
    }
    init(_ name: String) {
        return
    }
    static func == (lhs: PantryPlaceholderObject, rhs: PantryPlaceholderObject) -> Bool {
        return false
    }
}

I apologise for my bad code; this is largely intended to be a learning exercise (which is why I've gone through something like 5 iterations of it so far :slight_smile:).

Hmm I think you over-complicated your design quite a bit. Why do you insist on adding associated types in your protocols?

From what I could follow so far, you have the following entities:

  1. PantryObject which is supposed to act as a super-/entity for all your entities. I don't really see any reason for having the objectType properties there, because generally the point of abstraction is to not know or care about the underlying implementing type.
    But the problems start at this line:
    associatedType idType: Hashable
    This means that any code, function or class that is meant to process entities conforming to PantryObject or any derived protocol from it will have to be generic in some type T: PantryObject where T.idType == some_actual_hashable_type. And this lock-up into a generic type will propagate also to any other client code that uses said functions or classes, untill eventually you will probably have to make the swiftUI view itself generic in this type.

  2. Next you have an entity that's supposed to represent a storage, for which you know the (id, name, description from PantryObject and specifically the type of the items that are stored? (associatedtype typeType: PantryObject) and a specific type of the entity that represents the location? (associatedType locType: PantryObject)? So again the client code using instances of this protocol will have to be generic in some type S: PantryStorageObject where S.idType == ..., S.typeType == ..., S.locType == ..., S.storageType == .... > and why would you need such concrete type information there? Also what is the point of the last one, the associatedtype storageType: PantryStorageObject ?

  3. Same issues apply for usages of PantryItemObject due to its associatedtype storageType: PantryStorageObject as in the examples above. Also the func formateDate(_ : Date) -> String looks like something belonging in the view or presentation layer, not necessarily here in the model layer.

  4. I guess PantryOrganizer is supposed to be the ItemStorage from the first example, a.k.a the protocol that hides the database implementation details, but this won't happen again because it has so many associated types in it. You wont be able to have @EnvironmentObject var database: PantryOrganizer<X, Y, Z, etc..>

  5. Nothing new to mention about BasePantryOrganizerObject except that you make it generic in <ItemType, UnitType, KindType, LocationType> but then you have these methods that are generic in T :

    func createObject<T: PantryObject>(_ name: String, args: [String: Any]) -> T {
        fatalError("createObject must be overridden")
    }
    func createStorageUnit<T: PantryStorageObject>(_ name: String, kind: KindType, location: LocationType? = nil) -> T {
        fatalError("createStorageUnit must be overridden")
    }

Did you mean to use the ItemType and UnitType here?

This would not surprise me at all. I will keep apologising for the code :slight_smile:. (Give me a nice, C-based kernel and filesystem. But that's why I'm doing this, to try to break out of my comfort zone, and learn new things.)

Because I couldn't quite figure out how to do it otherwise. I knew my eventual goal would use several different back-ends; I'd done an implementation of this that subclassed everything, but hooo boy keeping Firebase and Core Data objects in sync with the base classes' properties was complicated and ugly. In particular, I had hoped to be able to use the Core Data classes directly, and that's why I thought protocols would be good, which then led to generics.

Something like (off the top of my head, sorry, writing this on an iPad instead of my laptop)

class PantryItem: PantryItemObject {
    var id: UUID
    var name: String
    // and so forth
}

and then

extension CDPantryItem: PantryItemObject {
    // the various hooks
}

The type for a Core Data item is UUID?; with Firebase, if I use their nice Swift-specific hooks, it's @DatabaseID var id: String?. So that's three different types for the ID.

And then if I want to search based on an ID, I have to have a type of some sort in the protocol, for the method prototype. No? E.g.

func findItem(itemID: idType) -> PantryItemObject

For the last question, about the create methods... I ended up trying them as global functions, and then just moved them inside the base class there. NB: the base class was added way last, and was an attempt to figure things out, before I ended up asking questions :slightly_smiling_face:.

So, to summarize my real goal for all this: I was hoping to be able to use the Core Data-generated classes through extensions, rather than embedding them as instance variables inside a wrapping class, and that is what lead me al the way down this path, because the memory-only classes are completely different from the CD classes, and the same for Firebase. (Well, not completely different -- just, in the cleanest implementation, different enough to be descended from different classes, but clearly [to me] calling out for protocols.)

I can make things work, in an ugly way, but I was trying to embrace Swift.

Does any of this make sense? I realize I'm not the best at articulation a lot of times.

This is really not necessary. Nobody is here to make you feel bad about your code :slightly_smiling_face:

Ok, after time and the various comments and answers here, I've come to some conclusions.

  • Protocols aren't really meant for complex structures. Delegates, and Identifiable, are good examples of protocols.
  • Because of the compile-time checking, Swift is even more restricted with generics than I am used to, and certainly far more so than Objective C.
  • Because of both of those, I cannot use protocol conformance to interchange two entirely different classes, as long as the difference includes type differences.
  • Also because of that, @EnvironmentObject in SwiftUI can't handle that at all.
  • I clearly do not understand how to use some in any way, sense, or form. Alas.

Thus leading me to conclude that, in order to achieve my goal of run-time decisions for the back-end, I must:

  • All of the types must be the same. That is, for a container view model (as I pasted up there), the contained objects must be the same (or at least with the same class parents) in each of the back-end classes, meaning that, for example, CDItem must have a class ancestor in common with FIRItem, and each of the common properties (e.g., id) must be the same.
  • I could possibly use generics in SwiftUI to achieve some of this, but I am not at all sure how well it would scale. And @EnvironmentObject would bite me.
  • I could still have protocols defining things (and this would be useful for a variety of reasons), but given the structures I come up with, they would not be interchangeable, meaning that I would probably have to use a compile-time flag to choose between them. Or I could do everything in ObjC :slightly_smiling_face:.
  • Which all means that, if I want run-time checking, I need to wrap everything in a single set of classes, and then subclasses would have to use wrappers around things like the Core Data types. (As opposed to my hope of extending the Core Data types to meet the protocol.) (Which I can still do, but not and have run-time decisions.)

Are my conclusions sensible, or have I missed something?

Merci beaucoup encore.

Yes.

Not necesarrily "the same" as in having common class ancestors but more like, they must expose exactly the same interface and they could if they implement the same protocol. Seems to me that the idType is causing you trouble here, because different databases use different types. Maybe try to see if you can unify this field to be a string across all implementations

Well, not just idType; having each of the contained types (e.g., PantryItem vs CDPantryItem) being different also comes up. I'm currently trying to think about abstracting those to just basic types (e.g, String and UUID), and see what that looks like. I think I'll have to whip out the paper and pen to do that :slightly_smiling_face:.

Part of the way to do that, though, would mean passing around object IDs, rather than the objects, and then looking them up. That is, right now, for example, I say something like

addItem(item, in: storageUnit)

but I'd have to change it to, I think,

addItem(item, in: storageUnit.id)

and change the item structure to have

var storageID: UUID

instead of

var storage: StorageUnit

That thought got me trying to play with some this weekend, and thus my comment about clearly not understanding it at all.

Thanks again :slightly_smiling_face:

So that's back to confusing me again. I can get various things to conform to a protocol -- as an example, I could change things to have

protocol PantryItemObject: ObservableObject, Identifiable {
    var id: UUID { get }
    var name: String { get }
    var itemCount: Int { get set }
    var addedDate: Date { get set }
    var storageUnit: UUID { get }
}

but then I don't understand how I would make a protocol, without an associated type, that allows for getting an array of them. E.g., what I currently have as something like:

class PantryStorage: PantryStorageObject {
    // ...
    func contents() -> [PantryItemObject]
}

doesn't work. And I can't use [some PantryItemObject].

As I think I said, I can try converting everything to using a UUID, but the complexity there seems to be pretty enormous. Unless I'm misunderstanding, which I hope is the case :slightly_smiling_face: (But it means, as an example, I lose the ability to use ObservableObject, because I've only got indirect references to everything, rather than references to the objects themselves.)

This is because you made PantryItemObject conform to Identifiable which in itself is a protocol with an associated type.

Maybe instead of putting references to entities in the model layer in the EnvironmentObject, try putting viewModels there and find a way to convert between viewModels and pantry items.

Try to convert everything to use a String

D'oh. Ok, that explains some more!

That doesn't actually change any of the complexity -- it's still an indirect reference, not a reference to the actual type. Hm. Can I do something like

struct ItemDetailView: View {
    @ObservedObject var item: PantryItemObject
    init(itemID: UUID) {
        self.item = findItem(itemID)
    }
    // ...
}

where findItem(_ id: UUID) -> some PantryItemObject, and either be a global function or a class method?