1

Problem Summary

I have a generic view subclass TintableView<T>: UIView, which implements a protocol TintStateComputing with identical associated type T. TintStateComputing's constrained extension implementation is not being called; its unconstrained extension implementation is called instead.

The TintStateComputing protocol has a function computeTintState() -> T, which does what it sounds like: checks the local state properties, and returns the according instance of T.

I want to write extension implementations of func computeTintState() -> T on TintStateComputing, constrained on the type of T. For example, with a ControlState enum:

extension TintStateComputing where T == ControlState {
    func computeTintState() -> T {
        return self.isEnabled ? .enabled : T.default
    }
}

However, in order to complete the protocol conformance, I think I need to account for other values of T. So I've also stated an unconstrained extension to TintStateComputing. This unconstrained extension implementation is always being called, instead of the constrained implementation.

extension TintStateComputing {
    func computeTintState() -> T {
        return _tintState ?? T.default
    }
}

Playground Testbed

import UIKit

// MARK: - Types

public enum ControlState: Int {
    case normal, enabled, highlighted, selected, disabled
}

public protocol Defaultable {
    static var `default`: Self { get }
}

extension ControlState: Defaultable {
    public static let `default`: ControlState = .normal
}

// MARK: - TintStateComputing declaration

public protocol TintStateComputing {
    associatedtype TintState: Hashable & Defaultable
    func computeTintState() -> TintState
    var _tintState: TintState? { get }
    var isEnabled: Bool { get }
}

// MARK: - TintableView declaration

class TintableView<T: Hashable & Defaultable>: UIView, TintStateComputing {
    // `typealias TintState = T` is implictly supplied by compiler
    var _tintState: T?
    var isEnabled: Bool = true { didSet { _tintState = nil }}

    var tintState: T  {
        get {
            guard _tintState == nil else {
                return _tintState!
            }
            return computeTintState()
        }
        set {
            _tintState = newValue
        }
    }
}

// MARK: - Unconstrained TintStateComputing extension

extension TintStateComputing {
    func computeTintState() -> TintState {
        return _tintState ?? TintState.default
    }
}

// MARK: - Constrained TintStateComputing extension

extension TintStateComputing where TintState == ControlState {
    func computeTintState() -> TintState {
        return self.isEnabled ? .enabled : TintState.default
    }
}

// MARK: - Test Case

let a = TintableView<ControlState>()
a.isEnabled = true
print("Computed tint state: \(a.tintState);  should be .enabled") // .normal
print("finished")

Workaround

I realized this morning that since (at least for now) what I'm really trying to accomplish is handle the isEnabled: Bool flag on the view, I could follow the same pattern as used for Defaultable to define a default 'enabled' case.

public protocol Enableable {
    static var defaultEnabled: Self { get }
}

extension ControlState: Defaultable, Enableable {
    public static let `default`: ControlState = .normal
    public static let defaultEnabled: ControlState = .enabled
}

At that point, I can really eliminate the TintStateComputing protocol, and update my view's tintState: T implementation to account for the flag directly.

var tintState: T  {
    get {
        guard _tintState == nil else { return _tintState! }
        return self.isEnabled ? T.defaultEnabled : T.default
    }
    set {
        _tintState = newValue
    }
}

It's not as generalized as putting the implementation in a constrained extension, but it will work for now. I think that if I have future subclasses with multi-dimensional tint-state types (e.g. 'enabled' + 'in-range') I'll be able to address via override.

struct CalendarState: Equatable, Hashable, Defaultable, Enableable  {
    let x: Int

    static let `default`: CalendarState = CalendarState(x: 0)
    static let defaultEnabled: CalendarState = CalendarState(x: 1)
}

class ControlTintableView: TintableView<ControlState> {}
class CalendarView: TintableView<CalendarState> {}

let a = ControlTintableView()
a.isEnabled = true
print("ControlTintableView computed tint state: \(a.tintState);  should be: .enabled") // .enabled

let b = CalendarView()
b.isEnabled = true
print("CalendarView computed tint state: \(b.tintState);  should be: CalendarState(x: 1)") // CalendarState(x: 1)

1 Answer 1

1

The problem is that there is only one specialization of TintableView, and it's based on what it knows from its own definition. When compiling the class, it considers computeTintState(), sees that TintState is not promised at that point to be exactly ControlState, and so compiles-in the more general version.

In order to do what you want, when it encounters TintableView<ControlState> it would need to completely reconsider and recompile the TintableView class. Swift doesn't currently do that. In this case, I don't consider that a bug. I think this code is trying to be too magical and is abusing extensions, but that's just my opinion on it. If you believe Swift should handle this kind of case then I recommend opening a defect at bugs.swift.org.

Keep in mind what would happen if TintableView were in one module and the TintState == ControlState extension were in another (say in the module with the let a =). In that case, it would be impossible to get the behavior you're asking for, because one module can't respecialize another module (it may not have the source code available). Would you consider this code good if it behaved one way when these were in one module, but have different visible behaviors if they were in different modules? That's why I consider this too tricky and bug-prone. This split-module specialization problem happens all the time, but it mostly just impacts performance (and stdlib uses private compiler directives to improve that because it's a special case).

1
  • 2
    thanks for the response. It certainly is trying to be magical :) It's a little troubling that it's allowed semantically by the compiler. I'd naively expect the recompilation to happen, inter-module, to support the constrained behavior; and the compiler to throw an error when trying to extend a type with constraint from outside the model. I managed to reproduce it in another case based on a Sundell example, so I'm writing up a summary, and will see at the end whether it makes sense to file a ticket.
    – Stan
    Commented Jun 29, 2018 at 0:41

Not the answer you're looking for? Browse other questions tagged or ask your own question.