Any way to avoid the forced type conversions?

I have the following code which functions correctly but relies on forced casts. Is there any way to write it without the forced casts?

public protocol Sub {
    associatedtype Event
  typealias Lift<ParentEvent> = (Event) -> ParentEvent

  func map<ParentSub: Sub>(_ lift:
    @escaping Lift<ParentSub.Event>) -> ParentSub
}

enum AppSub<EventType>: Sub {
  typealias Event = EventType

  case discoveredDevices((BluetoothDevice) -> Event)
  case receivedDeviceData((BluetoothData) -> Event)

  func map<ParentSub: Sub>(_ lift: @escaping Lift<ParentSub.Event>) -> ParentSub {
    switch self {
    case .discoveredDevices(let cb): 
      return AppSub<ParentSub.Event>.discoveredDevices({ device in lift(cb(device)) }) as! ParentSub
    case .receivedDeviceData(let cb): 
      return AppSub<ParentSub.Event>.receivedDeviceData({ data in lift(cb(data)) }) as! ParentSub
    }
  }
}

(runnable version at SelfassuredFrequentMath - Swift Repl - Replit)

You're converting AppSub<ParentSub.Event> to ParentSub, which would fail whenever ParentSub is not an AppSub instance (hence the need for force unwrapping). Not sure how I can fix that, though. It depends on whether you have only one generic conforming to Sub (AppSub), and whether you really need Sub protocol (which seems quite tricky).

The Sub type is intended to be framework code as opposed to AppSub which would be in application code so it's not possible to eliminate the Sub type.

The intention is really to return a Sub<ParentEvent> but I'm not sure how to achieve this with Swift. Possibilities seem to be opaque types or implementing an AnySub. As far as I can tell opaque types don't support generics? So it looks like AnySub might be the way to go? In that case I suppose Sub would look like

public protocol Sub {
    associatedtype Event
  typealias Lift<ParentEvent> = (Event) -> ParentEvent

  func map<ParentEvent: Sub>(_ lift:
    @escaping Lift<ParentSub.Event>) -> AnySub<ParentEvent>
}

I believe you are running into the problem that opaque types were intended to solve (but do not, in fact, solve, except for SwiftUI).

You want the map method to return some value whose type conforms to Sub. You want that type's Event to be the ParentEvent of the lift argument's type. And that's all you really want to say about the return type. You want the implementation of map to get to pick the exact return type, because it might be different for each implementation of map.

This is exactly what opaque returns types are for: letting the method choose the return type, and only promising that the type chosen conforms to a protocol. You want to declare this:

func map<ParentEvent>(_ lift: @escaping (Event) -> ParentEvent)
     -> some Sub where some Sub.Event == ParentEvent

but that's not legal Swift.

I think your solution is going to have to involve an AnySub type eraser.

2 Likes

If map actually needs to be implemented differently for each Sub-conforming type, then I think you will have to use AnySub. But if in fact you can implement map once for all Sub-conforming types, then you could also use Combine's solution. Change map from a protocol requirement to an extension method:

extension Sub {
    func map<NewEvent>(_ transform: @escaping (Event) -> NewEvent) -> MapSub<Self, NewEvent> {
        return MapSub(upstream: self, transform: transform)
    }
}

struct MapSub<Upstream: Sub, Event>: Sub {
    var upstream: Upstream
    var transform: (Upstream.Event) -> Event

    // implement Sub's requirements here
}

You'll still end up wanting AnySub, but you won't need it as often.

1 Like

Right, that's confirmed my suspicions then. That's a good point about using an extension function. I've been planning to allow for multiple Events to be emitted from Subs which would require custom mapping. Perhaps that's a goal not worth persuing though if it significantly increases the complexity of the API (it would push the map implementation into app code rather than the framework).

I haven't implemented a type eraser yet but it looks like it's a rite of passage for Swift devs :sweat_smile: Hopefully one day generic protocols will happen :grin:

This morning I had a go at using an AnySub type. Sub now looks like

public protocol Sub {
    associatedtype Event
  func map<ParentEvent>(_ lift:
    @escaping (Event) -> ParentEvent) -> AnySub<ParentEvent>
}

with an implementation of Sub looking like

enum AppSub<EventType>: Sub {
  typealias Event = EventType

  case discoveredDevices((BluetoothDevice) -> Event)
  case receivedDeviceData((BluetoothData) -> Event)

  func map<ParentEvent>(_ lift: @escaping (Event) -> ParentEvent) -> AnySub<ParentEvent> {
    switch self {
    case .discoveredDevices(let cb): return AppSub<ParentEvent>.discoveredDevices({ device in lift(cb(device)) }).eraseToAnySub()
    case .receivedDeviceData(let cb): return AppSub<ParentEvent>.receivedDeviceData({ data in lift(cb(data)) }).eraseToAnySub()
    }
  }
}

so the forced type conversions are gone which a good improvement. Implementation is at LinearSharpOperatingenvironment - Swift Repl - Replit if you're curious.

I did try getting it to work with the original Sub declaration

public protocol Sub {
    associatedtype Event
  typealias Lift<ParentEvent> = (Event) -> ParentEvent

  func map<ParentSub: Sub>(_ lift:
    @escaping Lift<ParentSub.Event>) -> ParentSub
}

but I couldn't find a way to avoid forced type conversions.

I'll also explore using MapSub at least so that I can get a good feel for the technique and tradeoffs.

1 Like

I've managed to get AppSub looking a bit clearer now, it separates the generics handling for the protocol from the implementation so that type inference can be used.

enum AppSub<EventType>: Sub {
  typealias Event = EventType

  case discoveredDevices((BluetoothDevice) -> Event)
  case receivedDeviceData((BluetoothData) -> Event)
  case currentTime(every: Int, action: (Date) -> Event)

  func map<ParentEvent>(_ lift: @escaping (Event) -> ParentEvent) -> AnySub<ParentEvent> {
    return doMap(lift).eraseToAnySub()
  }
  
  func doMap<ParentEvent>(_ lift: @escaping (Event) -> ParentEvent) -> AppSub<ParentEvent> {
      switch self {
      case .discoveredDevices(let cb): return .discoveredDevices({ device in lift(cb(device)) })
      case .receivedDeviceData(let cb): return .receivedDeviceData({ data in lift(cb(data)) })
      case .currentTime(every: let period, action: let cb): return .currentTime(every: period, action: { currentTime in lift(cb(currentTime)) })
      }
  }
}