Generic Algorithms Without Constraints

It’s often a red flag if we see duplicated code and we generally try to reduce it as much as possible. When we control all the code, we can massage structures to make this task easier but we don’t always have this flexibility.

This post explores how to remove duplication when two functions share logic but act on unrelated types with no shared interface - using Kotlin and Swift as examples.

Let’s look at a more challenging case.


The Problem

The algorithm itself isn’t important here, but consider the following starting point:

struct InputA { let name: String; let tag: String? }
struct InputB { let name: String; let tag: String? }

struct Output {
    let name: String
    let tag: String

    init(inputA: InputA, tag: String) {
        self.name = inputA.name
        self.tag = tag
    }

    init(inputB: InputB, tag: String) {
        self.name = inputB.name
        self.tag = tag
    }
}

func processInputAs(inputs: [InputA]) -> [Output] {
    inputs
        .compactMap { input in
            input.tag.map { Output(inputA: input, tag: $0) }
        }
}

func processInputBs(inputBs: [InputB]) -> [Output] {
    inputBs
        .compactMap { input in
            input.tag.map { Output(inputB: input, tag: $0) }
        }
}
data class InputA(val name: String, val tag: String?)
data class InputB(val name: String, val tag: String?)

data class Output private constructor(val name: String, val tag: String) {
    constructor(inputA: InputA, tag: String) : this(inputA.name, tag)
    constructor(inputB: InputB, tag: String) : this(inputB.name, tag)
}

fun processInputAs(inputs: List<InputA>): List<Output> {
    return inputs
        .mapNotNull {
            it.tag?.let { tag -> Output(inputA = it, tag = tag) }
        }
}

fun processInputBs(inputs: List<InputB>): List<Output> {
    return inputs
        .mapNotNull {
            it.tag?.let { tag -> Output(inputB = it, tag = tag) }
        }
}

As shown above, both processInputAs and processInputBs are nearly identical. The issue is that these two functions operate on different input types with no common supertype. This means we can’t write a simple generic function with a constrained type parameter, because we have no common supertype to constrain against.

So how might we solve this duplication if our types did share a common shape?


When a Shared Constraint Exists

If InputA and InputB did have a common supertype we could trivially create a single function that handles them both.

protocol Input {
    var name: String { get }
    var tag: String? { get }
}

struct InputA: Input {
    let name: String
    let tag: String?
}

struct InputB: Input {
    let name: String
    let tag: String?
}

struct Output {
    let name: String
    let tag: String

    init(input: Input, tag: String) {
        self.name = input.name
        self.tag = tag
    }
}

func processInputs(inputs: [Input]) -> [Output] {
    inputs
        .compactMap { input in
            input.tag.map { Output(input: input, tag: $0) }
        }
}
interface Input {
    val name: String
    val tag: String?
}

data class InputA(override val name: String, override val tag: String?): Input
data class InputB(override val name: String, override val tag: String?): Input

data class Output private constructor(val name: String, val tag: String) {
    companion object {
        operator fun <T: Input> invoke(input: T, tag: String) = Output(input.name, tag)
    }
}

fun processInputs(inputs: List<Input>): List<Output> {
    return inputs
        .mapNotNull {
            it.tag?.let { tag -> Output(input = it, tag = tag) }
        }
}

Unfortunately, in our original framing, InputA and InputB share no common supertype. This can occur in situations where you don’t own the types as they come from a third-party library or maybe the types are generated with something like OpenAPI.

When I hit this situation, I tend to look at the two functions side by side and highlight the differences.


Reveal The Differences

For these functions the differences are going to be:

In the listing below I’ve highlighted these areas with multiple ?s.

func processInputs(inputs: [??????]) -> [Output] {
    inputs
        .compactMap { input in
            input.???.map { ??????(input: input, tag: $0) }
        }
}
fun processInputs(inputs: List<?????>): List<Output> {
    return inputs
        .mapNotNull {
            it.????.let { tag -> ??????(input = it, tag = tag) }
        }
}

Solving the Problem

For the first point, we can’t avoid not knowing the type - there’s nothing common to constrain to. So we’ll have to just introduce a type parameter.

func processInputs<T>(inputs: [T]) -> [Output] {
    inputs
        .compactMap { input in
            input.???.map { ??????(input: input, tag: $0) }
        }
}
fun <T> processInputs(inputs: List<T>): List<Output> {
    return inputs
        .mapNotNull {
            it.????.let { tag -> ??????(input = it, tag = tag) }
        }
}

Next, we need to teach the function how to read the tag value. As we only know we have a T we can provide a function that takes a T and returns the String? we expect.

func processInputs<T>(
    inputs: [T],
    tag: (T) -> String?
) -> [Output] {
    inputs
        .compactMap { input in
            tag(input).map { ??????(input: input, tag: $0) }
        }
}
fun <T> processInputs(
    inputs: List<T>,
    tag: (T) -> String?
): List<Output> {
    return inputs
        .mapNotNull {
            tag(it)?.let { tag -> ??????(input = it, tag = tag) }
        }
}

Finally, we need to teach the function how to create the new outputs.

func processInputs<T>(
    inputs: [T],
    tag: (T) -> String?,
    makeOutput: (T, String) -> Output
) -> [Output] {
    inputs
        .compactMap { input in
            tag(input).map { makeOutput(input, $0) }
        }
}
fun <T> processInputs(
    inputs: List<T>,
    tag: (T) -> String?,
    makeOutput: (T, String) -> Output
): List<Output> {
    return inputs
        .mapNotNull {
            tag(it)?.let { tag -> makeOutput(it, tag) }
        }
}

With this helper function in place we can update our original processInputAs and processInputBs functions to call through to the shared code.


Pulling it Together

func processInputAs(inputs: [InputA]) -> [Output] {
    processInputs(inputs: inputs, tag: \.tag, makeOutput: Output.init)
}

func processInputBs(inputs: [InputB]) -> [Output] {
    processInputs(inputs: inputs, tag: \.tag, makeOutput: Output.init)
}

private func processInputs<T>(
    inputs: [T],
    tag: (T) -> String?,
    makeOutput: (T, String) -> Output
) -> [Output] {
    inputs
        .compactMap { input in
            tag(input).map { makeOutput(input, $0) }
        }
}
fun processInputAs(inputs: List<InputA>): List<Output> {
    return processInputs(inputs, InputA::tag, Output.Companion::invoke)
}

fun processInputBs(inputs: List<InputB>): List<Output> {
    return processInputs(inputs, InputB::tag, Output.Companion::invoke)
}

fun <T> processInputs(
    inputs: List<T>,
    tag: (T) -> String?,
    makeOutput: (T, String) -> Output
): List<Output> {
    return inputs
        .mapNotNull {
            tag(it)?.let { tag -> makeOutput(it, tag) }
        }
}

Conclusion

We don’t always need shared supertypes to make algorithms generic. With a little upfront work teaching the algorithm how to access and create data, we can operate on unrelated types just as effectively. Both Kotlin and Swift make this especially clean through their support for passing method and initializer references - keeping our higher-order functions readable and expressive.