Trouble inheriting from a protocol with an associatedtype

I recently read an informative article about UIFontMetrics and scaling fonts and as a personal exercise I attempted to abstract the main ideas into a protocol.

protocol Font {
    associatedtype Style
    func font(forStyle style: Style) -> UIFont
}

Here I chose to define Style as an associatedtype because I didn't want to couple it directly to UIFont.TextStyle. Instead, I defined another protocol inheriting from Font.

protocol ScalableFont: Font where Style == UIFont.TextStyle {
    // fun stuff...
}

extension ScalableFont {
    func scaledFont(forStyle style: Style, compatibleWith traitCollection: UITraitCollection? = nil) -> UIFont {
        let metrics = UIFontMetrics(forTextStyle: style)
        let base = font(forStyle: style)
        // some fun stuff...
    }
}

I then wrote a test function that let me preview my font in a UIStackView.

func previewFont(_ font: ScalableFont) {
    // more fun stuff...
}

But... hello darkness, my old friend:

Protocol 'ScalableFont' can only be used as a generic constraint because it has Self or associated type requirements

Personal note: I loath this error. On many occasions it's stopped me from truly embracing protocols in more situations and shunted me towards generic structs/classes which I don't find as expressive or extendable.

I'm baffled by this error because the compiler has been told that Style is equal to UIFont.TextStyle. I apologise ahead of time if this is covered in the Generics Manifesto, I've read it through twice since it was published but I'm clearly still missing something.

Can anyone shed some light on this real world example?

That is not correct. StyledFont is only another protocol that refines your Font protocol, and it is constrained so that conforming types must have the exact same Style type as the constrain says. The StyledFont protocol can still be upcasted to Font which will remove the constraint and then you‘ll end up with the same error again.

I'm not trying to be difficult, I understand all those words but I still don't quite understand why I can't use ScalableFont as a parameter type without changing the function declaration to this:

func previewFont<T: ScalableFont>(_ font: T) {
    // ...
}

Is it only because of upcasting? Wouldn't the place where I try and upcast it to Font be the more appropriate place to show this compile time error?

Maybe I'm showing my naïveté but previewFont(_ font: ScalableFont) knows all of the types involved. The value font is a value with all of the functions of Font and ScaledFont but only those where Style is equal to UIFont.TextStyle.


Hmm... I might have typed my way to an understanding.

Is the difference between the plain (and invalid) previewFont(_:) and the generic previewFont<T: ScalableFont>(_:) the moment when it needs to call font(forStyle:) and it doesn't know the underlying type of font so it doesn't know which implementation to reach for?

Here's some code to help me reason about this.

extension ScalableFont {
    func font(forStyle style: Style) -> UIFont {
        return UIFont.preferredFont(forTextStyle: style)
    }
}

struct AvenirNext: ScalableFont {
    func font(forStyle style: Style) -> UIFont {
        let font = //... some black magic here
        return font
    }
}

struct System: ScalableFont {}

let f = AvenirNext()
previewFont(f) // if previewFont(_ font: ScalableFont) compiled

Then from inside previewFont(_:) it doesn't know if it should reach for the default implementation, AvenirNext's implementation, or System's (non-existent) implementation of font(forStyle:). Whereas the generic version is kind of like passing in a promise of functionality (aka a protocol) and where to go find the implementations?

Am I on the right track here?

The actual problem here (IIRC) is that, currently, the compiler does not actually check whether or not all associated types are fully constrained, only whether or not the protocol or its ancestors have declared an associatedtype (or used Self).

This second constraint is easier to implement, and works for most cases, but does prevent some cases that seem like they should work, like yours.

3 Likes

This is the associatedtype: Style inherited from Font.

This would be ... where Style == UIFont.TextStyle which, at the moment (swift 4.2), doesn't qualify as fully constrained?

That means ideally there will be a future release of Swift where previewFont(: font: ScalableFont) would compile?

ps. Thanks all for being kind about my misunderstandings. I'll read up on the Generics Manifesto again this Sunday for good measure :wink:

It is fully constrained, but the compiler currently doesn't check, and thus that fact cannot affect the compilation.

The primary thread about these restrictions would probably be:

1 Like

Thanks for the clarification and thread link. I read through the main points and I understand the situation much better.