Generics, protocols, and creating objects

This is a bit of a contrived situation, since it's a pared-down version of a bigger project, just to try to get the same results.

The gist is that I have a protocol, and some classes that conform to that protocol, and then a class that has a list of objects, and then a collection that has multiple of those lists. Simple, really.

Since I have a bunch of code (usually SwiftUI) that shouldn't care what the actual object is, just that it conform to protocols, I tried using both protocols and generics. And mostly it works, except when it comes to creating objects. That, I haven't been able to figure out how to do. I've tried using global functions with generics, overloaded functions with specific types, member functions, and on and on. And I assume I am missing something obvious, because it seems like such a common thing to do. (Side note: the reason I did not put an init method into the protocol was because I want to be able to use this with Core Data -- and I can't add initializers to NSManagedObject, which is frustrating because all I want to do is add an extension to CDBook that wraps around getting/setting the properties.)

Compiling this, I get the error:

/tmp/t.swift:87:37: error: type of expression is ambiguous without more context
            let newObject = self.list.library.newEntry(self.newName)
                            ~~~~~~~~~~~~~~~~~~^~~~~~~~~~~~~~~~~~~~~~

So, am I missing something obvious? Or is this not really doable in Swift, and I have to completely change how I think to get a solution?

import Foundation
import SwiftUI

protocol Namable: ObservableObject {
    associatedtype idType: Hashable
    var id: idType { get }
    var name: String { get set }
}

class Song: Namable, ObservableObject {
    var id = UUID()
    var name: String
    var band: String

    init(_ name: String, band: String = "<unknown>") {
	self.name = name
	self.band = band
    }
}

struct ISBN: Hashable {
    var id: String
    static func ==(lhs: ISBN, rhs: ISBN) -> Bool {
	return rhs.id == lhs.id
    }
    func hash(into hasher: inout Hasher) {
        hasher.combine(self.id)
    }
    init() {
	self.id = UUID().uuidString
    }
}

class Book: Namable, ObservableObject {
    var id = ISBN()
    var name: String
    var author: String
    var fiction: Bool

    init(_ name: String, author: String = "Public Domain", fiction: Bool = false) {
	self.name = name
	self.author = author
	self.fiction = fiction
    }
}

class Collection<T: Namable>: ObservableObject {
    @Published var objects = [T]()
    var library: Library? = nil
    
    func addObject(_ object: T) {
	self.objects.append(object)
    }
    func findObject(_ name: String) -> T? {
	return self.objects.first(where: { $0.name == name })
    }
}

class Library: ObservableObject {
    @Published var books = Collection<Book>()
    @Published var music = Collection<Song>()
    init() {
	self.books.library = self
	self.music.library = self
    }
    func newEntry<T: Book>(_ name: String) -> T where T: Namable {
	return Book(name) as! T
    }
    func newEntry<T: Song>(_ name: String) -> T where T: Namable {
	return Song(name) as! T
    }
}

let library = Library()

library.books.addObject(Book("Huck Finn", author: "Sameul Clemens"))
library.music.addObject(Song("Sing"))

struct AddObjectView<T: Namable>: View {
    @ObservedObject var list: Collection<T>
    @State var newName: String = ""

    var body: some View {
	Form {
	    TextField("Name", text: self.$newName)
	    Button("Add") {
		let newObject = self.list.library.newEntry(self.newName)
		self.list.addObject(newObject)
	    }
	}
    }
}

Since the error is ambiguous expression, you should follow the accompanied error information. To that, you'll see that you have two competing definitions of Library.newEntry:

func newEntry<T: Book>(_ name: String) -> T where T: Namable
func newEntry<T: Song>(_ name: String) -> T where T: Namable

So the real question is how do you tell the compiler if you want to create Song or Book. Does each Library type contain only Books or Songs, bot not both? How does AddObjectView know if it's adding Book or Song.

Blast it, did leave out a version. In the Collection class, I have

    func newObject(_ name: String) -> T {
        return self.library!.newEntry(name)
    }

So, there, the compiler does know the type, because it's part of how the collection is created (and that's why I have the two newEntry functions in the library). But trying that gets me

/tmp/t.swift:58:23: error: no exact matches in call to instance method 'newEntry'
        return self.library!.newEntry(name)
                             ^
/tmp/t.swift:69:10: note: candidate requires that 'Book' inherit from 'T' (requirement specified as 'T' : 'Book')
    func newEntry<T: Book>(_ name: String) -> T where T: Namable {
         ^
/tmp/t.swift:72:10: note: candidate requires that 'Song' inherit from 'T' (requirement specified as 'T' : 'Song')
    func newEntry<T: Song>(_ name: String) -> T where T: Namable {

Does it make sense why I want to do this, or at least why I tried to do it this way?

I appreciate your time and assistance! I apologise for paring it down too much.

I don't get your data structure. It seems you have a collection of T (Collection<T>), and each collection contains a library that contains books and songs (Library).

  • Do you mean to have each collection of T contains a library of T instead?
  • Do you want to distinguish between two instances of books/songs, even if they have the same id and are equatable via ==?
  • Do Book and Song need to be Observable? Can you wrap them in State instead of ObservedObject?

Side note:

  1. It should be easier to have Namable refines Identifiable, which covers the id part:

    protocol Namable: Identifiable, ObservableObject {
      var name: String { get set }
    }
    
  2. Avoid name collision if possible. Collection is already a protocol in Swift. No need to confuse the compiler even further.

Again, this was a pared down version; the actual code does have the protocol depend on Hashable, which covers Identifiable.

The reason I have a reference to Library in Collection goes back to Core Data: I want to be able to create an object, but creating CD objects requires more context; however, the Library object will have that context, and can deal with only having the name for creation. (Same with Firebase as a back end; I could also end up putting all that information in the the list-class, aka Collection in my badly-named example here.) I'm trying to use protocols instead of subclassing because in some cases the back-end means that the classes may have to be completely different (e.g., the ID type may not be a UUID).

I am not using "Collection" or any other name collision in my actual code; again, I was trying to simplify things so that I had a very small example of the problem I was running into, which also would (hopefully!) demonstrate why I was doing things that way.

You need to have a definition of newEntry that is valid for all possible types of T (all Namable given the sample code), not just Book or Song. It should be something of this signature:

func newEntry<T: Namable>(_ name: String) -> T

The other two candidates are too restrictive that the compiler can't use them.

That sounds contrived. I think you can leverage EnvironmentValues.context to interact with Core Data. You need to pass in the context ofc, but you can do:

struct AddObjectView {
  @Environment(\.managedObjectContext) var context
}

If it's not just the context that you need, you can also add other variables to EnvironmentValues to pass in in a similar manner.

Assuming that T is a subclass of NSManagedObject, it could look more like this:

struct AddObjectView<T: Namable>: View {
  @Environment(\.managedObjectContext) var context
  ...

  var body: some View {
    ...
    Button("Add") {
      self.list.addObject(T(context: context))
    }
  }
}

Don't you mean depend on Identifiable, which covers Hashable?

You need to have a definition of newEntry that is valid for all possible types of T (all Namable given the sample code), not just Book or Song . It should be something of this signature:

func newEntry<T: Namable>(_ name: String) -> T

I do not see how I can do that, and create objects of different types. That is, given the code I have, how would I have a function (or method) with that signature, which knows which type it is supposed to create?

That sounds contrived. I think you can leverage EnvironmentValues.context to interact with Core Data. You need to pass in the context ofc, but you can do:

I still need to know when to create a Core Data something, vs just an in-memory something, vs a FireStore something, and so forth. Also, does @EnvironmentObject work with non-SwiftUI code?

Don't you mean depend on Identifiable , which covers Hashable ?

I actually was thinking Equatable, but Identifiable implies Hashable? If so, I did not know that, and is certainly useful information. Thanks!

Honestly, I don't know. I don't use FireStore or anything other than Core Data. You do get T to use though, so maybe you can use initializers (T.init), which can be added as a protocol requirement, and implemented/added to the conformers. You know about the requirement better than me, so :woman_shrugging:. All I'm saying is that, if you want to use newEntry like that, you need to figure out how to write newEntry like that. Likely you'll need to figure this out in one form or another.

Unfortunately, no.

I meant that Identifiable.ID conforms to Hashable, not that Identifiable conforms to Hashable itself. Since Namable already contains a hashable id, you essentially get a lot of interoperability for free if you just have it refines Identifiable.

Honestly, I don't know. I don't use FireStore or anything other than Core Data. You do get T to use though, so maybe you can use initializers ( T.init ), which can be added as a protocol requirement, and implemented/added to the conformers

That's why I asked. And I can't use init, because of NSManageableObject.

The problems protocols are supposed to solve are related to the same problems multiple inheritance tries to solve; the problem that generics try to solve is reducing the amount of identical code you have to write. But the two don't seem to be very in-sync with Swift, unless I keep missing something -- which is entirely possible, which is why I have asked :grinning:.

As I said, I can't use subclassing because there are type and inheritance differences between the various classes I need to use; I can duplicate all of my code and all of my SwiftUI, but that seems to defeat the entire purpose of classes, protocols, and generics.

I am very frustrated with this. :sob:

Can't you still add convenience initializer?

Can't you still add convenience initializer?

If I subclass it, but not if I use an extension, as I recall. (I tried doing that.) But that doesn't fix the other problem I would have, which is that creating a Core Data object needs to have extra context -- and that creating a Firestore object needs different, extra context -- and that in order to know what to do, I need to be able to know what kind of object I'm creating.

Grad you mention this. I'm about to ask. Is there any commonality between these managers. You want to create a Namable object using one manager, or another manager, or yet another manager (Core Data manager, FireStore manager, in-memory manager). Surely you have some protocol to rope in all these managers, or have some manager-agnostic wrapper around these managers.

You can add it to an extension alright, and convenience initializer is inherited in most cases I can think of with Core Data scenarios.

Bah, not at the laptop now so I have less ability to research, but as I recall:

Is there any commonality between these managers. You want to create a Namable object using one manager, or another manager, or yet another manager (Core Data manager, FireStore manager, in-memory manager).

Yes, and that’s what I have in the protocols :smile:. But at some point, I need to be able to create a new object (and different kinds) of objects.

The easiest way to do this for the various data sources I’ve experimented with is in one of the collections. In the crappy example here, Collection or Library. Or I could have entirely different UI for everything, which gets annoying, or subclass and use wrappers around all the various properties (which means I essentially re-implement each object class for each data source method).

As for the initializer: I ran into problems putting an init method into the protocol, it didn’t seem to let me do a convenience one. I haven’t fully experimented with that. But then you can’t create a Core Data-created class with just init() so I’m back to not having sufficient context or ability to know what type of object to create.

I am cheerfully admitting the likelihood that I’m missing something, or need to think about it an entirely different way. But I don’t know what I am missing, or how I need to attack the problem, in those cases.

Well, you showed me Namable protocol, but never show me Manager protocol. And it seems you can't instantiate a general Namable instance with NSManagedObjectContext given its concrete type. Likely I'm also missing some context.

If I interpret it verbatim, you tried to implement the initializer inside the protocol?

protocol Namable {
  init() { ... }
}

You need to add the requirement to the protocol, then add implementation in a separate extension.

protocol Namable {
  static func create(context: NSManagedObjectContext)
}

extension NSManagedObject: Namable {
  static func create(context: NSManagedObjectContext) {
    ...
  }
}

Let's start with a concrete case. If you want to create an instance of Book using Core Data, what do you need? Can you put all the requirements in a single global function?

func create(context: NSManagedObjectContext, otherData: ...) -> BooK { ... }

What about the FireStore case?

func create(context: FireStoreContext, ...) -> Book { ... }

What's the different between the two function signatures? How do they change if you switch to Song.


Note that Book needs to be a subclass of NSManagedObject since Core Data requires all instances to be so. Admittedly if FireStore requires a different class, it might not be possible to reconcile the two systems.

If I interpret it verbatim, you tried to implement the initializer inside the protocol?

No. You can't put a convenience initializer in a protocol.

protocol Namable: ObservableObject {
    associatedtype idType: Hashable
    var id: idType { get }
    var name: String { get set }
    convenience init(_ name: String)
}

/tmp/t.swift:8:17: error: convenience initializer not allowed in non-class type 'Namable'
    convenience init(_ name: String)
    ~~~~~~~~~~~~^

As for the rest... So the actual program is pretty large and complicated, and I've been trying to make small tests cases for filing bugs and asking for help. It's essentially an inventory system. In this case, the classes and protocols are representative, as is the note about Core Data (as I just started trying to add that after rewriting my code to use protocols and templates, instead of increasingly-complex hierarchical classes).

I've implemented three data sources: in-core, Firestore, and Core Data. Firestore doesn't need much more context, but it does need authentication, it needs a listener thread to be notified of new data, all the data types are optional, and it uses different decorators for at least some of the properties. Core Data uses notifications to be told when data is changed, but doesn't need authentication, but does need the store context, and all the data types are optional.

So in that, ahem, context, the design I was hoping for with this rewrite was pretty simple:

  • A protocol for the basic object -- which has just a name and an ID. The protocol requires some support functions, most of which can be implemented in a protocol extension.
  • Two different inherited protocol for the next basic objects -- in the sample case I'm doing here, think of a bookshelf or a bookcase. (And a bookcase has an id, and a name, so it's inheriting the base protocol; it also has things like number of shelves, a location, etc.)
  • A list type for all of the objects, similar to what I've got in my test code way above. It's a generic class. I created a protocol for it, but haven't done anything with it because of the problem I first wrote about, namely, not knowing how to create an object inside a templated, protocoled class.
  • And an organizer to keep all of that together. Currently no protocol, and since there should only be one, I was planning on subclassing it for Firestore and Core Data. Virtually all of the methods should be the same; the only differences come down to the extra properties, how individual objects are created, and what happens when they're changed or deleted.

If I use subclassing instead of protocols and generics, I don't have this problem -- it always goes to the right class' methods. But it gets really messy, and there's a lot more duplicated code than I, personally, think there should be.

As I said, it's very frustrating.

I did mention this earlier, since you're using Core Data, you already require each data type to be a non-generic subclass of NSManagedObject. So you're already knee-deep into the old-school class hierarchy. That might interfere with the designing of the protocol system. It does not help that you're also interacting with FireStore and in-core, which I have absolutely no idea how to use, so I can't really suggest much. At worst, what you're doing ends up being a class hierarchy (which I believe is the status quo). What surprises me also, is that you somehow managed to combine NSManagedObject with ObservableObject. The two have a very different report system, that I didn't think it is possible.

I couldn't really parse the requirements, but that's not gonna stop me from trying. Maybe something like this?

protocol Namable: NSManagedObject, Identifiable, ObservableObject {
  var name: String { get set }
}
extension NSManagedObject: Namable {
  @NSManaged var name: String
  public var id: NSManagedObjectID { objectID }
}

protocol Manager {
  associatedtype ContextualData
  func create<T>(_: T.Type, data: ContextualData) -> T
}
extension NSManagedObjectContext: Manager {
  func create<T>(...) { ... }
}

There's also a good chance that Namable is actually a class:

class Namable: NSManagedObject {
  @NSManaged var name: String
}

You can put initializer in a protocol extension:

protocol Namable: ObservableObject {
    associatedtype idType: Hashable
    var id: idType { get }
    var name: String { get set }
    init(_ name: String)
}

extension Namable {
  init(_ name: String) { ... }
}

class A { }
extension A: Namable {
  required convenience init(_ name: String) { ... }
}

convenience and required are just contracts for classes, which protocol plays no part of, and doesn't much care.

what you're doing ends up being a class hierarchy

What I'd hoped to do, with this approach, was to have all of the UI, and a bunch of the simple management, be described using protocols, and then have multiple sets of distinct class hierarchies for each data source. (So there'd be the in-core class hierarchy, the CD class hierarchy, and the Firestore class hierarchy.)

Part of the UI is "create a new object." The UI shouldn't at all care what the data source is, so it just needs something that follows a protocol -- that makes sense, right? Which means a generic. And most of the management (the view model part, if I understand both the term, and how I've structured it) is also fairly generic -- provide a way to find an object, to list kinds of objects, delete them, rename them, and add them.

I had it working very nicely until I added CD as a data source. But I may have some new approaches to try now, due to this discussion.

That's not what I meant. I meant that you might need to turn models (Book, Song) into one big class hierarchy. You may still be able to use protocol for data source (CoreData context, FireStore context) though.

There are two things I want to point out:

  • You never show what the protocol for data source looks like. All I've seen is Library, so I can't say if it's feasible or not.
  • Protocols and generics are two different things. Given that you want to include Core Data, which is an Obj-C framework, generics might not be possible. At least, the models and data sources might need to be non-generic (though they can conform to protocols alright). We may still, and most likely will, have generic views.

These are the protocols I currently have.

protocol InventoryObject: ObservableObject, Identifiable, CustomStringConvertible, Hashable {
    var id: UUID { get }
    var name: String { get set }
    var description: String { get }
    static var objectType: String { get }
    func isStorageUnit() -> Bool
    func isStorageItem() -> Bool
}

protocol InventoryStorageObject: InventoryObject {
    associatedtype typeType: InventoryObject
    associatedtype locType: InventoryObject
    associatedtype storageType: InventoryStorageObject
    
    init(_ name: String, type: typeType, location: locType?)
    var storageType: typeType { get }
    var storageLocation: locType? { get }
    var description: String { get }
    static func newStorageUnit(_ name: String, id: UUID, type: typeType, location: locType?) -> storageType

}

protocol InventoryItemObject: InventoryObject {
    associatedtype storageType: InventoryStorageObject
    var inventoryStorageUnit: storageType { get }
    var addedDate: Date { get }
    var checkDate: Date? { get }
    var expireDate: Date? { get }
    
    func formateDate(_ : Date) -> String
}

I meant that you might need to turn models ( Book , Song ) into one big class hierarchy.

No, that's what I meant, too. E.g.,

class InventoryObjectModel : InventoryObject {
    // ...
}
class BookModel:  InventoryObjectModel {
    // ....
}
class SongModel: InventoryObjectModel {
    // ....
}
class InventoryItemModel: InventoryObjectModel, InventoryItemObject {
    // ...
}
class InentoryList<T: InventorObject> {
    var objectList = [T]()
    // I think I also have var listType: T.Type { return T.self }
    // But I don't think it was helpful to me [yet anyway] so I may have tossed out that change.
    // ...
}
class InventoryStorageModel: InventoryStorageObject {
    var bookInventory = InventoryList<BookModel>()
    var songIventory = InventoryList<SongModel>()
    var itemInventory = InventoryList<InventoryItemModel>()
    // ...
}

My thoughts were that, given that sort of design, I could have a Firebase subclass for InventoryStorageModel, but an entirely different set of classes that still conformed to the protocols. And, again, same for Core Data. With the result being that, at launch time, I could decide whether to instantiate InventoryStorageModel, InventoryStorageModelFirebase, or InventoryStorageModelCoreData, and have everything else Just Work. (With, for example, an extension for the Core Data classes to conform to the correct protocol, that would be wrappers for the CD properties/methods.)

But all of that does depend on me being able to correctly create an object from the UI at some point.

Without generics, using only classes, I can check at runtime to see what the type is. And that would let me have something horrible like

if object.list.itemType == BookModel.self {
    // SwiftUI/code to create a book
}
if object.list.itemType == SongModel.self {
    // SwiftUI/code to create a song
}
if object.list.itemType == InventoryItemModel.self {
    // SwiftUI/code to create an inventory item
}

But with generics, I can't. Not as easily and clearly, anyway, but I'm starting to get a grasp on maybe how.

I apologise for this long post, and hope that it makes sense to anyone else but me.

You want to create objects, all the types which follow a particular protocol. However one of the versions, for Core Data, has to have a mandatory extra parameter in their initializer (for the managed object context) that no other version needs.

You could use an extra layer of indirection. Instead of using particular initializers of a model, have an associated model-factory type. Factories will conform to another protocol you'll define for creating objects. A factory will have an associatedtype for one of your original factory types. However, the factory protocol will not have Initializer requirements. This lets you customize the factory for Core Data models to be initialized with a NSManagedObjectContext, in which the factory method will use behind the scenes to create your CoreData-oriented models.

Terms of Service

Privacy Policy

Cookie Policy