"Automatic" Type Conformance

So an idle holiday musing.

Take a peek at the following function taken from an image caching service.

    private func cachedImage(for path: String?) -> AnyPublisher<UIImage?, Never> {
        guard let path = path else {
            return Just(nil)
                .eraseToAnyPublisher()
        }
        if let image = imageCache.object(forKey: NSString(string: path)) {
            return Just(image)
                .eraseToAnyPublisher()
        }
        return image(for: path)
            .handleEvents(receiveOutput: { [weak imageCache] (image) in
                imageCache?.setObject(image, forKey: NSString(string: path))
            })
            .eraseToAnyPublisher()
    }

Notice anything, uh... shall we say... redundant?

Yep. It's the eraseToAnyPublisher operator, needed to convert a specific type to our type-erased AnyPublisher return type.

That convention is probably the one single thing that makes doing anything with Combine feel cumbersome and clunky as opposed to doing the same thing in RxSwift. But what if we could do something like the following?

extension Just: TypeConvertable {
    var convertable: AnyPublisher<Output, Never> {
        self.eraseToAnyPublisher()
    }

And, that, along with a few other definitions of the same kind, would let us do...

    private func cachedImage(for path: String?) -> AnyPublisher<UIImage?, Never> {
        guard let path = path else {
            return Just(nil)
        }
        if let image = imageCache.object(forKey: NSString(string: path)) {
            return Just(image)
        }
        return image(for: path)
            .handleEvents(receiveOutput: { [weak imageCache] (image) in
                 imageCache?.setObject(image, forKey: NSString(string: path))
            })
    }

Much cleaner. This could be accomplished by something similar to the "ExpressibleBy" protocol...

protocol TypeConvertable {
    associatedtype ReturnType
    var convertable: ReturnType { get }
}

So we're not really talking about "automatic" type conversion in as much as we're discussing a mechanism for defining specific types of predefined conversions. Define a type conversion, and the compiler could "fix" the difference between a provided type and an expected type. In the above case we're talking about the type required by the function's return type.

Of course, defined this way, we could have only one conformance on a given type. So perhaps we could simply tell the compiler that this type provides conversions and have it then match on the function return type.

protocol TypeConvertable {}

enum SomeEnum: Int {
    case a
    case b
}

extension SomeEnum: TypeConvertable {
    func convertable() -> Int { self.rawValue }
    func convertable() -> Double { Double(self.rawValue) }
}

This would let us pass our enum to any function that desired an Int or Double as a parameter. That kind of thing might be frowned upon by some, so consider...

struct MyView: ViewBuilder {
    var body: View {
        TextView("Hi there!")
    }
}

extension ViewBuilder: TypeConvertable {
    var convertable: UIView {
        self.body.asUIView()
    }
}

Adding an UIView TypeConvertable conformance to ViewBuilder allows any view builder definition to be passed as a parameter to any function expecting a UIView.

class MyViewController: UIViewController {
    override func viewDidLoad() {
        view.addSubview(MyView())
    }
}

And without requiring us to do...

view.addSubview(MyView().asUIView())

Again, this isn't automatic conversion. We're explicitly defining conversions that can be accomplished safely and that we've considered beforehand.

So that's the idea. From my perspective, continually erasing types or explicitly defining rawValues or view conversions is boilerplate that gets in the way of expressing our actual intent and, worse, continually exposes the underpinnings of the abstractions we're trying to express.

Thoughts?

3 Likes

The goal of modern generics improvements in recent versions of Swift, including some opaque types, primary associated types, and expanding existentials to protocols with associated types, is to make manual type erasure containers and such conversions unnecessary. Combine pre-dates those features.

7 Likes

No doubt. And yet it exists and we need to deal with it in its current form. There are also the various other examples shown where such a thing could be useful.

As mentioned, this sort of conversion process is similar to that provided by the recent "ExpressibleBy" extensions to the language.

Small tangent: some is insufficient if you are a framework author actively trying to prevent clients from introspecting implementation details that are surfaced as types—E.x. publishers in Combine are often best implemented as subjects, but clients shouldn't be able to as?-cast them back to their concrete type and call send on them. An explicit box is necessary (as far as I know) in these cases.

This existed in beta versions of the language and was spelled __conversion() (see, e.g., unit conversions). As I understand things, it was removed because it could cause type resolution in the compiler to go pathological. ExpressibleBy was introduced later as a scoped subset of the functionality that could be more strictly managed. I imagine any proposal that allows for arbitrary implicit type conversion would be untenable, but if you could spell out a set of constraints that sufficiently narrows the scope of the problem (like ExpressibleBy, or the recent change to allow interchangeable use of CGFloat and Double types) you could maybe get some traction.

2 Likes

I got sidetracked on this a while back and found a way to do away with eraseToAnyPublisher() for my (admittedly very narrow) use case. Given the following result builder API intended for hooking up 'actions', I wanted to get rid of the final line, as it added a lot of noise on longer files:

let settings = ActionStream { stream in 

    SettingsScreen.CloseButtonWasPressed
        .subscribe(on: stream)
        .map { Sounds.Play(sound: .swoosh) }
        // .eraseToAnyPublisher() // Do not want

    SettingsScreen.VolumeSliderWasChanged
        .subscribe(on: stream)
        .throttle(for: .milliseconds(100), scheduler: RunLoop.main)
        .map { Audio.ChangeVolume(to: $0.value) }
        // .eraseToAnyPublisher() // Do not want

}

With result builders you can make the builder try and resolve each statement to some desired 'convertible' type, with something like the following implementation. First, put together a protocol to bring together anything you want to be convertible:

typealias AnyActionPublisher = AnyPublisher<Action, Never>

protocol AnyActionPublisherErasable {
    func eraseToAnyActionPublisher() -> AnyActionPublisher
}

extension Publisher where Output == Action, Failure == Never {
    func eraseToAnyActionPublisher() -> AnyActionPublisher {
        AnyPublisher(self)
    }
}

// List of all the publishers you want to be able to automatically convert.
extension Publishers.Map: AnyActionPublisherErasable where Output == Action, Failure == Never {}
extension Publishers.ReplaceError: AnyActionPublisherErasable where Output == Action {}


Then, catch that protocol in your result builder definition:

static func buildExpression(_ publisher: AnyPublisher<Action, Never>) -> Something {
    // Use the publisher to return something of the type you actually want from your builder.
}

static func buildExpression(_ publisher: any AnyActionPublisherErasable) -> Something {
    buildExpression(publisher.eraseToAnyActionPublisher())
}

Maybe this is of some use to somebody!

I'm not more than cursorily familiar with Combine, but clients can't as? cast to the concrete type if the concrete type isn't public. If, as you describe, one is dealing with implementation detail surfaced as a type, then modulo current expressivity limitations surrounding opaque types (e.g., not being able to spell out that func b() has the same return type as that of func a(), etc.) it ought to be feasible to make the concrete type unutterable by the client:

public protocol P { }
internal struct S: P { }
public func f() -> some P { S() }
2 Likes

That's a good point. Clients can still see the type (print(type(of: f())) prints S, at least in my test project), but I don't know off the top of my head how you'd make use of that in a pure-Swift context.

In combine's case the two provided concrete implementations of Subject are public because they're very useful building blocks for the vast majority of publishers, so both internal implementations and clients who construct their own publishers want to use them. The authors may choose to jump through the hoops necessary to have a private and public version of these types for the ergonomic improvements if it were redone today, but then clients who are themselves frameworks would need to reinvent the same technique—admittedly this is all a very platform-framework-vendor specific problem that strictly shouldn't impact application code, and only rarely impact framework code.

I didn't know about __conversion. Interesting. I mean, you could get maybe 95% of the way there with a single TypeConvertable that only allowed one extension of that type on any given type, but that seems like a fairly strict limitation.

I could maybe see the complier having issues if you allowed someFloat + someDouble and each had an automatic conversion to the other type or some such, but it seems like it would be relatively straightforward to implement on an assignment to a type, as a passed parameter, or, as illustrated, matching to a function return type.

Can you replace the return type from AnyPublisher to any Publisher and get the same result?

    private func cachedImage(for path: String?) -> any Publisher<UIImage?, Never> {
        guard let path = path else {
            return Just(nil)
        }
        if let image = imageCache.object(forKey: NSString(string: path)) {
            return Just(image)
        }
        return image(for: path)
            .handleEvents(receiveOutput: { [weak imageCache] (image) in
                imageCache?.setObject(image, forKey: NSString(string: path))
            })
    }

Nope. Gives an error when the consumer of cachedImage tries to use it.

        _ = cachedImage(for: user.picture?.medium)
            .map { $0 }
            .sink { _ in

            }

Gives Member 'map' cannot be used on value of type 'any Publisher<UIImage?, Never>'; consider using a generic constraint instead

3 Likes

So no other thoughts on the pros/cons of implementing something like TypeConvertable?