Idiomatic abstraction of C union types

I'm writing some Swift bindings to a C library, and I've run into a situation where I'm uncertain what an idiomatic Swift abstraction for a particular C paradigm would be.

In the C library, there is a type which looks something like the following:

struct input_device {
    enum input_device_type type;

    union {
        struct pointer_device *pointer;
        struct keyboard_device *keyboard;
    }
}

enum input_device_type {
    INPUT_DEVICE_TYPE_POINTER,
    INPUT_DEVICE_TYPE_KEYBOARD,
}

Initially, the approach I took to abstracting this type was roughly like this:

public final class InputDevice {
    let pointer: UnsafeMutablePointer<input_device>
    public lazy var device: InputDeviceType = InputDeviceType(self)

    public init(_ pointer: UnsafeMutablePointer<input_device>) {
        self.pointer = pointer
    }
}

public enum InputDeviceType {
    case pointer(PointerDevice)
    case keyboard(KeyboardDevice)

public init(_ inputDevice: InputDevice) {
    let inputDevicePointer = inputDevice.pointer

    switch inputDevicePointer.pointee.type {
    case INPUT_DEVICE_TYPE_POINTER:
        self = .pointer(PointerDevice(inputDevicePointer.pointee.pointer))
    case INPUT_DEVICE_TYPE_KEYBOARD:
        self = .keyboard(KeyboardDevice(inputDevicePointer.pointee.keyboard))
    default:
        fatalError("Unknown input device type")
    }
}

This abstraction works decently, but my concern with it is mostly the constant disambiguation required to work with it. The C library has a callback which returns an input_device, at which point it needs to be disambiguated; for example:

func onNewInput(device: InputDevice) {
    switch device.device {
    case .pointer:
        onNewPointer(device)
    case .keyboard:
        onNewKeyboard(device)
    }

    ...
}

These methods then basically create handlers for pointers and keyboards respectively, which need to hold references to those InputDevices. But then what happens when I need to use the inner type? Well, one thing I could do is add a convenience getter to the handler; something like:

var keyboard: KeyboardDevice {
    switch device.device {
    case .keyboard(let keyboard):
        return keyboard
    default:
        fatalError("Expected input device to be of type keyboard")
    }
}

This getter could also ostensibly be on the InputDevice type itself, but regardless, this isn't a particularly pretty way of handling it.

So then I had an idea of a different way I could deal with this.
First, I created a "dummy" protocol which the inner input devices could conform to: InputDeviceProtocol.

Then I rewrote InputDevice to be like this:

public final class InputDevice<Device: InputDeviceProtocol> {
    let pointer: UnsafeMutablePointer<input_device>

    public init<Device: InputDeviceProtocol>(_ pointer: UnsafeMutablePointer<input_device>, type: Device.Type) {
        self.pointer = pointer
    }

    public convenience init(_ pointer: UnsafeMutableRawPointer) {
        let inputDevicePointer = pointer.assumingMemoryBound(to: input_device.self)

        switch inputDevicePointer.pointee.type {
        case INPUT_DEVICE_TYPE_POINTER:
            self.init(inputDevicePointer, type: PointerDevice.self)
        case INPUT_DEVICE_TYPE_KEYBOARD:
            self.init(inputDevicePointer, type: KeyboardDevice.self)
        default:
            fatalError("Unknown input device type")
        }
    }
}

Then I can simply conform PointerDevice and KeyboardDevice to InputDeviceProtocol. This model has some neat advantages, namely that once instantiated, we know what the inner type is, so I can do this:

extension InputDevice where Device == KeyboardDevice {
    public var keyboard: KeyboardDevice {
        return KeyboardDevice(pointer.pointee.keyboard)
    }
}

I could also potentially create a protocol such as KeyboardDeviceProtocol with some keyboard-specific default implementations which both KeyboardDevice could conform to as well as InputDevice<KeyboardDevice> (though I don't know if I actually want to do this, just an idea).

The advantage here is that the input device only needs to be disambiguated once, but this is also where the problem arises. The callback I mentioned earlier--you hook into it by passing a callback to a Signal type, something like this:

public final class SomeObject {
    ...
    public let onNewInput: Signal<InputDevice<InputDeviceProtocol>>
    ...
}

someObjectInstance.onNewInput.listen(callback)

Except, oops! Now that there's a generic attached to InputDevice, this doesn't work anymore. InputDeviceProtocol isn't a concrete type. I've thought about various ways to get around this, but I'd like to hear from the community what a clean, idiomatic way to deal with this might be.

Note that this code is not a verbatim copy of the code I'm actually working with, it's a sort of simplified version that I wrote up on the spot to give a general idea of what I'm doing. If you spot an inconsistency, it's probably because this code isn't precisely what I'm using.

Thanks!

Perhaps Iā€™m missing something here, but it seems like the idiomatic Swift representation would just be a struct for each underlying device type (so, a struct for keyboard_device and a struct for pointer_device, and a enum for input_device:

struct PointerDevice {
    var wrapped: UnsafeMutablePointer<pointer_device>
    // wrappers for pointer_device functions here
}

struct KeyboardDevice {
    var wrapped: UnsafeMutablePointer<keyboard_device>
    // wrappers for keyboard_device properties here
}

enum InputDevice {
    case pointer(PointerDevice)
    case keyboard(KeyboardDevice)
    
    init(_ input: UnsafeMutablePointer<input_device>) {
        switch input.pointee.type {
            case .INPUT_DEVICE_TYPE_POINTER:
                self = .pointer(PointerDevice(wrapped: input.pointee.pointer))
            case .INPUT_DEVICE_TYPE_KEYBOARD:
                self = .keyboard(KeyboardDevice(wrapped: input.pointee.keyboard))
        }
    }
}
2 Likes