Where is the line between using an existential vs generic?

Currently revisiting core modules of the project to review applicability of existentials there and possibly replace them with generics/opaque types, and while to some extent that is an easy job, some of cases start reminding me why I went with existentials instead of generics in the first place, and make me question where to draw the line.

Of course, it differs from project to project, but I think there is possible to make rough distinction, as we have libraries/frameworks, where it is probably better to sacrifice some internal convenience and use generics, or have cases where there is literally any type possible. There are performance-critical code, where you want to avoid abstractions at many levels, and clearly want to avoid existentials, yet it usually more isolated or domain-specific. All this cases brings almost no confusion for me, as it has certain specific criteria to base decision on. The use cases I most concerned about are for more or less "regular" client apps, with no demands on being extra-performant, just work reasonably, so difference of using existential vs generics might be not visible at all.

As an example, consider following part of UI code:

open class DataViewController<Provider, Listener>: UIViewController 
where Provider: DataCollectionProvider, Listener: ListActionsListener {
    private let provider: Provider
    private let listener: Listener

    public init(provider: Provider, listener: Listener) {
        self.provider = provider
        self.listener = listener
        super.init(nibName: nil, bundle: nil)
    }
}

public struct DataView<Provider, Listener>: UIViewControllerRepresentable
where Provider: DataCollectionProvider, Listener: ListActionsListener {
    private let provider: Provider
    private let listener: Listener

    public init(provider: Provider, listener: Listener) {
        self.provider = provider
        self.listener = listener
    }

    public typealias UIViewControllerType = DataViewController<Publisher, Listener>

    public func makeUIViewController(context: Context) -> UIViewControllerType {
        UIViewControllerType(
            viewModelPublisher: viewModelPublisher,
            provider: provider,
            commands: commands
        )
    }

    public func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) {
    }
}

Not counting that generic is a bit of verbose, it also goes viral if you want to have a wrapper or pass dependencies down as well. Comparing to using existentials, which look much cleaner:

open class DataViewController: UIViewController {
    private let provider: any DataCollectionProvider
    private let listener: any ListActionsListener

    public init(provider: any DataCollectionProvider, listener: any ListActionsListener) {
        self.provider = provider
        self.listener = listener
        super.init(nibName: nil, bundle: nil)
    }
}

public struct DataView: UIViewControllerRepresentable {
    private let provider: any DataCollectionProvider
    private let listener: any ListActionsListener

    public init(provider: any DataCollectionProvider, listener: any ListActionsListener) {
        self.provider = provider
        self.listener = listener
        super.init(nibName: nil, bundle: nil)
    }

    public typealias UIViewControllerType = DataViewController

    public func makeUIViewController(context: Context) -> UIViewControllerType {
        UIViewControllerType(
            viewModelPublisher: viewModelPublisher,
            provider: provider,
            commands: commands
        )
    }

    public func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) {
    }
}

That seems more appealing to me as there is less code to write and deal with, and from the performance perspective seems to have no difference. As this example of more generic code, which actually can have any provider & listener passed into, the issue actually arises every time you need to have stored properties.

Given all of that, it seems that one can go with existentials in this case and don't bother, which might be true, yet what I have observed over the journey of migration to reduced existentials, it seems that using more generics and less existentials actually leads to better design as current tools allow to avoid verbose parts on usage side.

So, given that aim is for better design choices and code clarity, rather than performance details, what are the thoughts on such "day-to-day" use of generics and existentials?

1 Like

I personally place relatively little weight on "looking cleaner"; save aesthetics for places they benefit your end users. But, you're also quite correct that in many cases the performance difference simply does not matter, so why not?

As always with performance questions, the answer lies in measurement. If you have performance targets, test before shipping to make sure you hit them on the lowest end devices you're targeting, and use Instruments and the other performance tools to investigate when you don't, then you're well ahead of most apps and should have little trouble determining when and where to change your design.

2 Likes

When you are your own user, and decisions force you (and team) to bear verbose generics through the code, it starts to bother. I agree that to some extent this argument is weak, and in those parts when it is I simply OK with having complex generics. That is what beyond make me question choices.

Well, apart from performance questions, there might be other drivers. For example, in same case of attempt to replace existentials with generics outside of UI code (which is probably still the biggest "troublemaker" in that question), I have discovered that type has just so many dependencies that were hidden by simplicity of passing existentials, and actually revealed bad design choices.

I probably have made too much emphasis on performance at the beginning. While that was one of the initial drivers to review, as for now that's the least concern of mine regarding what to choose.

Yup, this is why I usually try to go with generics if I can.
I'd just consider as my "personal advice born from my experience", i.e. it could just be me, but I always like if the type definitions I have can tell me something akin to "I am this kind of thing with these capabilities" instead of just "I am something that can do X, but maybe more".

Protocol-constrained generics can result in, well, long-ish type declarations, but on the other hand they give much more precise information than any. I see protocols as an indicator of "what a thing is".
Generics then "group" common code based on what "things" various types are, i.e. while their declaration may become longer, the end result is less code, but it's still as "precise" as having separate identical functions for several types.

Existentials can do the same, but you lose information when reading, I think. I mostly use them when I need to store heterogenous collections and the like.

Oh, and when the line-length (or count) bothers me too much, I sometimes use shortened private typealiases in my types to make things a little more readable (but that obviously does not work everywhere and you have to be careful not to "clutter" names all around).

4 Likes

I agree with all of the above, and I would also prefer generics.

For me, protocols are still primarily a way to express constraints on type placeholders in generic functions or generic types (even though you can use them for more than that nowadays, cf. existentials).

1 Like

The introduction of opaque types did a lot to relieve some of generics' downsides. I feel it's one of Swift's best (and least-sung) features, when it comes to generics (after type inference).

I think the broader question of dynamic vs static typing applies largely the same to existentials vs generics. There are pros and cons both ways.

I do feel that the Swift culture is a bit dogmatically pro-generics, to the point of self-harm sometimes. Existentials will not, in fact, give you cooties - they should probably be used a lot more than they currently are.

2 Likes

Thanks, that’s what I am currently leaning towards as well, still have some doubts. This part on loosing type information that is descriptive is pretty accurate. If think about this verbose declarations in long term, that's actually a plus.

Yes, opaque types, especially once they got extended from first introduction, has solved significant number of times when I have reached for an existential.

Yeah, probably. Still I find it hard to navigate without hints on types in such languages – exactly because of lack of information, and when Python have added type annotations was very happy, even though I heard a lot from devs that this is pointless and neglects duck typing benefits.

Both extremums can get you to self-harm. That's search for balance has brought up my question in the first place, as I don't want to go all-in with generics, neglecting reasonable type erasure, and feel that previously got too far with existentials as well. Not that they brought much troubles (except back-deploy on Apple platforms once any has been introduced and gave pretty upsetting crashes), but there are too much of them I see in the code.


As a thought, 'line' might be when dealing with protocols with primary associated types, since if you think a bit about this, any Collection<Int> looks kinda odd as you selectively erase (plus loose indexes) type information, and probably you'll be better of with some Collection<Int> or generic. On the other side, any Hashable seems fine as you just erase type completely.

1 Like