It is bad form to have unconstructible types whose only purpose is to vend typealiases witnessing associatedtype requirements?

sometimes my codebases evolve towards a pattern where i just have protocols that bundle type requirements and generic constraints, like:

protocol BranchElement<Key, Heads, Divergence>
{
    associatedtype Key
    associatedtype Heads:BranchElementHeads
    associatedtype Divergence:BranchElementDivergence
}

the protocol exists to mitigate proliferation of generic parameters, for example:

struct BranchBuffer<Element> where Element:BranchElement
{
}

instead of

struct BranchBuffer<Key, Heads, Divergence> where Heads:BranchElementHeads, Divergence:BranchElementDivergence
{
}

which eventually leads to fake empty enumeration types, whose only purpose is to hold “real” types witnessing the associatedtype requirements in the protocol.

enum Fake:BranchElement
{
    typealias Key = Int 
    struct Heads:BranchElementHeads {}
    struct Divergences:BranchElementDivergence {}
}

The main reason to not do this is that you will hurt Swift's ability to infer types since there is no way to know what Element is given Key, Heads and Divergences.

For example, presumably the BranchElement conformances actually hold onto some data, like this:

struct BranchBuffer1<Element> where Element: BranchElement {
  let key: Element.Key
  let heads: Element.Heads
  let divergences: Element.Divergences
}

With this style you have no choice but to specify the generic on BranchBuffer1 when creating it:

let buffer1 = BranchBuffer1(
  key: 1, 
  heads: Fake.Heads(), 
  divergences: Fake.Divergences()
) // 🛑 Can't infer Element

let buffer1 = BranchBuffer1<Fake>(
  key: 1, 
  heads: Fake.Heads(), 
  divergences: Fake.Divergences()
) // ✅ Element generic is required

Whereas if you do it in the simpler style, with a generic for each associated type:

struct BranchBuffer2<Key, Heads, Divergences>: BranchElement
where
  Heads: BranchElementHeads,
  Divergences: BranchElementDivergence
{
  let key: Key
  let heads: Heads
  let divergences: Divergences
}

…then you get to create a BranchBuffer2 without specify the generics at all:

let buffer2 = BranchBuffer2(
  key: 1, 
  heads: Heads(), 
  divergences: Divergences()
)
1 Like

IMO, when introducing a new protocol, it is helpful to ask "which generic algorithms can I write using this?"

If there can be some interesting relationship between those associated types which enables algorithms to be written, or if each of those non-constructible types has some unique semantic meaning, I think it can be an appropriate design. If it is quite literally only to save some typing and has no semantic meaning at all, then it may be more difficult to justify.

It is difficult to give a blanket answer that something is always "good form" or "bad form"; it depends on what you're trying to achieve.

1 Like

when i introduce a new protocol, it is most often because i have a generic type and i want to make the generic type conform to a different protocol, but i cannot do so without running into the “multiple conditional conformances” problem.

1 Like