A simultaneously simple yet probably fatally complex use of variadic generics

I'm curious if the upcoming variadic generics features will enable us to implement a function like transform(_:_:) that you can see used below:

extension String {
    var hasPrimeNumberOfCharacters: Bool {
        transform(
            self,
            { $0.count },
            { $0.isPrime }
        )
    }
}

The function takes a mandatory first argument which is the value to transform, and then a variadic list of transform closures, each one operating on the output type of the closure before it, with the first one operating on the initial value. In the example above the arguments are: String, (String)->Int, (Int)->Bool.

I've read a decent amount of the variadic generics documents, and if I had to give my best guess right now I'd say that my transform(_:_:) function will not be possible with the first iteration of variadic generics.

Below is an extremely rough sketch of the closest I can imagine to how such a function signature might be written. I found it necessary to invent many new pieces of syntax, which bolsters my guess that this will not be possible, at least initially if not ever.

func transform
    <InitialValue,
     each (T, U)>
    (_ initialValue: InitialValue,
     _ transform: repeat each (T)->U)
-> last U or InitialValue
where first T == InitialValue {

    ???? // How would one even implement this if the above syntax were valid?
}

I don't think this is likely to be ever be supported in Swift's variadic generics. It would require a pretty elaborate interdependence between the elements of a pack, and that's hard to express and deal with statically. This is pretty macro-y, which is why it can work well in a C++-like template model based on immediate substitution.

1 Like

Yeah, I'm pretty sure you can't specify relations between neighbouring variadic elements. But perhaps you could make this using result builders, specifically buildPartialBlock?

Something like this:

struct Transform<R, T> {
    let f: (R) -> T
}

@resultBuilder
enum Transformer<R> {
    static func buildPartialBlock<T>(first: @escaping (R) -> T) -> Transform<R, T> {
        Transform(f: first)
    }

    static func buildPartialBlock<T, U>(accumulated: Transform<R, T>, next: (T) -> U) -> Transform<R, U> {
        Transform { r in
            next(accumulated.f(r))
        }
    }
}

struct Test {
    let initial: String
    @Transformer<String> var transform: Transform<String, Bool>
}

func count(string: String) -> Int { string.count }
func isPrime(int: Int) -> Bool { true }

let test = Test(initial: "hello") {
    count
    isPrime
}
3 Likes

Understood.

For fun, I thought of one more version of the signature with some new invented syntax:

func transform
    <InitialValue,
     T[i]>
    (_ initialValue: InitialValue,
     _ transform: repeat (T[i - 1] or InitialValue)->T[i])
-> T.last or InitialValue

The ideas of note are:

  1. In this world, a generic parameter is made variadic by "subscripting it" in the generic parameter list in the way shown above.
  2. i is arbitrary - we could have written T[j] or T[hello].
  3. When writing a pack expansion type, T[i] is the standard way to reference the type corresponding to the "current iteration", but it is also acceptable to modify the index or completely ignore it, e.g.:
    T[i - 1]
    T[i + 1]
    T[0]
    T[2]
  4. However, only the type corresponding to the "current iteration" definitely exists. If I make any modifications to the index, I now have to account for the possibility that there is no type for the index I have given, and that is where my invented or keyword comes in. If you use a modified index then the compiler forces you to give a fallback type via the or keyword (which of course could be ?? instead, but I think maybe it's not good to have it look like an operation on values instead of types).
  5. This is why I have to use the or keyword in two places in the signature. The first one because if i == 0 then i - 1 == -1 (which would be invalid), in which case the fallback type of InitialValue will be used. The second usage of or is in the return type, because I want to return the last T but if no transform closures were given then there is no last T and therefore there needs to be a fallback type, which logically in this case should be InitialValue as well.

I'm probably very naive here but, wouldn't you be able to enumerate the types by overloading the function and calling it recursively?

func transform<InitialValue, T, each (U, V), W, X>
    (_ initialValue: InitialValue,
     _ firstTransform: (InitialValue) -> T,
     _ nextTransforms: repeat each (T) -> U
     _ lastTransform: (W) -> X
     ) -> X {
    return transform(firstTransform(initialValue), repeat each nextTransforms, lastTransform)
}

func transform<InitialValue, T, U>
    (_ initialValue: InitialValue,
     _ nextTransform: (InitialValue) -> T,
     _ lastTransform: (T) -> U
     ) -> U {
    return lastTransform(nextTransform(initialValue))
}

func transform<InitialValue, T>
    (_ initialValue: InitialValue,
     _ transform: (InitialValue) -> T
     ) -> T {
    return transform(initialValue))
}
1 Like

I don't think there is a need for new syntax in your example signatures:

func transform<Input, each Parameter, each Result, Output>(
  _ input: Input,
  applying function: repeat (each Parameter) -> each Result
) -> Output where (repeat each Parameter, Output) == (Input, repeat each Result) {
  fatalError()
}

or

func transform<Input, each Intermediate, Output>(
  _ input: Input,
  applying function: repeat (each (Input, repeat each Intermediate)) -> each (repeat each Intermediate, Output)
) -> Output {
  fatalError()
}

I expect the first signature to work in Swift 5.x as long as equality is implemented for variadic generics and tuples.

The second one removes redundancy, but would be trickier to implement.

Of course, to actually implement the function you would need to type erase everything and force cast all the intermediate results.

3 Likes

No. This kind of static pattern-matching through overloading works in the C++ template system because dependent overloads are late-bound during template instantiation. In Swift’s generics, names are eagerly bound in the generic definition, so your first overload there will fail to type-check.

1 Like

This cannot be supported in the current "thereotical model" because it cannot be split up into a series of same-type requirements where the left hand side of each requirement is a type parameter.

Without variadic generics, we have three "improper" forms of same-type requirements which can always be trasnformed into those satisfying the above invariant:

  • Int == Int is trivially true and can be discarded.
  • Int == T simplifies to T == Int.
  • Array<T> == Array<String> simplifies to T == String.

Anything else is an error:

  • Int == String, etc

With variadic generics, the following is actually similar to the Array example above because you can imagine that peeling off the tuple on both sides leaves you with a type parameter on one side and a concrete pack type on another, which don't have a spelling in the syntax but we can denote as Pack{...}:

  • (repeat each T) == (First, repeat each Rest) simplifies to T == Pack{First, repeat each Rest}

However, this requirement cannot be simplified in this manner:

  • (A, repeat each B) == (repeat each C, D)

No matter what you do, you will have a concrete type on both sides.

1 Like