Generic functions over protocols, and result builders-only function overloads

This is only a half-baked idea, but I can't find a similar discussion on the forums, and it's not mentioned in the Generics Manifesto, so I thought it might be valuable to share it here. There are bound to be lots of holes in this post. Please poke all that you find.

One day I was writing something like this using SwiftUI:

struct AccessibleImage: View {
    let optionalImage: Image?
    let alternativeText: Text
    
    var body: some View {
        optionalImage ?? alternativeText
    }
}

which didn't compile, because ?? requires the left operand's wrapped value and the right operand to have the same type, and optionalImage! and alternativeText don't have the same type.

One alternative for this specific logic is just using a pair of if-else clauses:

if let image = optionalImage {
    image
} else {
    alternativeText
}

which is a bit more wordy, but does the job well, and is clear to read.

However, it's common for users to chain functions/modifiers in result builders, and this presents a problem with if-else. Because it's not possible to chain functions directly onto the result of the clauses, either the chained functions need to be duplicated in each clause, or the if-else needs to be extracted into a computed variable or put in a wrapper:

// duplicate functions:

if let image = optionalImage {
    image
        .resizable()
        .frame(width: 100, height: 200)
        .background(.red)
} else {
    alternativeText
        .frame(width: 100, height: 200)
        .background(.red)
}

---

// extract into a computed variable:

var accessibleImage: some View {
    if let image = optionalImage {
        return image.resizable()
    } else {
        return alternativeText
    }
}

var body: some View {
    accessibleImage
        .frame(width: 100, height: 200)
        .background(.red)
}

---

// put into a wrapper:

Group {
    if let image = optionalImage {
        image
            .resizable()
    } else {
        alternativeText
    }
}
.frame(width: 100, height: 200)
.background(.red)

These 3 solutions for chaining functions with the if-else clauses work, but they produce a lot of visual clutter. Especially when the code is more complicated than this simple example, it becomes harder to understand what the code is doing from a glance.

I guess it's probably a common kind of boilerplate in code that prevalently uses opaque types, and maybe it warrants a feature that allows ?? to take different types that conforms to the same protocol for both operands and return an opaque type that conforms to the same protocol, so the above code can be simply expressed like this:

(optionalImage?.resisable() ?? alternativeText)
    .frame(width: 100, height: 200)
    .background(.red)

A problem though, is that although currently it's possible to overload functions for specific types, in this case something like this:

extension Optional {
	@ViewBuilder
	static public func ?? <T: View, U: View>(
        optional: T?,
        defaultValue: @autoclosure () throws -> U
    ) rethrows -> some View {
        switch optional {
        case .some(let value):
            value
        case .none:
            try defaultValue()
        }
    }
}

a new overload is required to work with every new protocol which the parameter types conform to. E.g. to make ?? work for different Scene-conforming types:

extension Optional {
	@SceneBuilder
	static public func ?? <T: Scene, U: Scene>(
        optional: T?,
        defaultValue: @autoclosure () throws -> U
    ) rethrows -> some Scene {
        switch optional {
        case .some(let value):
            value
        case .none:
            try defaultValue()
        }
    }
}

In order to define a function once and have it work for every protocol, the function needs to be generic over both protocols and concrete types that conform to those protocols at the same time. If the function returns an opaque types, as the examples above, then the result builder attribute that applies to it also needs to be generic over both protocols and concrete types that conform to those protocols at the same time. These are currently impossible.

I don't know if they'll ever be something possible in Swift, and even if they're possible, I don't know what works are need to enable them.

One idea I have is that instead of making result builder types generic over protocols (if it even makes any sense), maybe result builders' magic can be expanded a bit to allow function overloads that use result builder methods implemented by the result builder type whose instance they're called in. The following code example might explain the idea clearer:

protocol SupportsIfAndSwitch {
    associatedType Body: SupportsIfAndSwitch
    @SupportsIfAndSwitchBuilder var body: Self.Body { get }
}
protocol DoesntSupportIfAndSwitch {
    associatedType Body: DoesntSupportIfAndSwitch
    @DoesntSupportIfAndSwitchBuilder var body: Self.Body { get }
}

@resultBuilder
struct SupportsIfAndSwitchBuilder {
    static func buildBlock...(...) -> Result where Result: SupportsIfAndSwitch {...}
    static func buildEither<...>(first...) -> ConditionalResult<...> {...}
    static func buildEither<...>(second...) -> ConditionalResult<...> {...}
    enum ConditionalResult<T: SupportsIfAndSwitch, U: SupportsIfAndSwitch>: SupportsIfAndSwitch {...}
}

@resultBuilder
struct DoesntSupportIfAndSwitchBuilder {
    static func buildBlock...(...) -> Result where Result: DoesntSupportIfAndSwitch {...}
    // no implementation for 'buildEither' functions
}

extension Optional {
    // an attribute that tells the compiler that this overload is only available in some result builders
	@resultBuilderOverload
    // a possible syntax for expressing generic over protocols?
	static public func ?? <Left: P, Right: P, protocol P>(
        optional: Left?,
        defaultValue: @autoclosure () throws -> Right
    ) rethrows -> some P {
        switch optional {
        case .some(let value): value
        case .none: try defaultValue()
        }
    }
}

struct Foo0: SupportsIfAndSwitch {...}
struct Foo1: SupportsIfAndSwitch {...}
struct Foo2: SupportsIfAndSwitch {
    let optionalFoo0: Foo0? = ...
    let foo1: Foo1 = ...
    var body: some SupportsIfAndSwitch {
        optionalFoo0 ?? foo1
        // compiles,
        // because the '??' overload is available in 'SupportsIfAndSwitchBuilder'
        // because 'SupportsIfAndSwitchBuilder' implements `buildEither` methods
    }
}

struct Bar0: DoesntSupportIfAndSwitch {...}
struct Bar1: DoesntSupportIfAndSwitch {...}
struct Bar2: DoesntSupportIfAndSwitch {
    let optionalBar0: Bar0? = ...
    let bar1: Bar1 = ...
    var body: some SupportsIfAndSwitch {
        optionalBar0 ?? bar1
        // doesn't compile,
        // because the '??' overload is not available in 'DoesntSupportIfAndSwitchBuilder'
        // because 'DoesntSupportIfAndSwitchBuilder' doesn't implement `buildEither` methods
    }
}

I think a difficulty with allowing functions to be generic over protocols is that a type can conform to multiple protocols, so sometimes the compiler might not be able to figure out which protocol to use for a function:

func returnSelf<T: P, protocol P>(_ `self`: T) -> T {
    return `self`
}

let answer = returnSelf(42) // is 'P' 'SignedInteger', or 'BinaryInteger', or 'FixedWidthInteger', or...

But this should be easily avoidable by providing the necessary type information like this:

let answer = returnSelf<_, protocol SignedInteger>(42)

And sometimes the compiler should be able to infer the protocol:

var computedAnswer: some BinaryInteger {
    returnSelf(42)
}

let existentialAnswer: FixedWidthInteger = returnSelf(42)

What does everyone think?

1 Like
Terms of Service

Privacy Policy

Cookie Policy