4

I am trying to create a function, which takes a tuple of higher-kinded types and applies a function to the types within the higher-kinded types.

In the example below, there is a trait Get[A] which is our higher-kinded type. There is also a tuple of Get's: (Get[String],Get[Int]) as well as function from (String,Int) => Person.

Scala-3 has a Match-Type called InverseMap which converts the type (Get[String], Get[Int]) into what is essentially the type (String,Int).

So the ultimate goal is to write a function which can take a tuple with any number of Get[_] types and a function whose input matches the InserveMap types and finally return a Get[_], where the wrapped type is the result of the function.

I have attempted to create a function called genericF below to show the desired behavior, though it may not be correct -- but I think it does at least show the proper intent.

  case class Person(name: String, age: Int)
  trait Get[A] {
    def get: A
  }
  case class Put[A](get: A) extends Get[A]
    
  val t: (Get[String], Get[Int]) = (Put("Bob"), Put(42))
  
  val fPerson: (String,Int) => Person = Person.apply _
  
  def genericF[T<:Tuple,I<:Tuple.InverseMap[T,Get],B](f: I => B, t: T): Get[B] = ???
  val person: Get[Person] = genericF(fPerson, t)

I have set up a Scastie here: https://scastie.scala-lang.org/OleTraveler/QIyNHPLHQIKPv0lgsYbujA/23

Travis Stevens
  • 2,198
  • 2
  • 17
  • 25
  • I don't think `Get` is an HKT - it has a type parameter, but that parameter is not itself a type constructor – user Oct 13 '20 at 17:07
  • Well, for one, `fPerson` doesn't take a 2-tuple, it takes two parameters, so I guess you want another pair of parentheses there – user Oct 13 '20 at 17:32
  • I believe `Get` is a first-order type because you can create a proper type by passing in a type to the type parameter, eg `Get[String]` is a proper type. – Travis Stevens Oct 13 '20 at 17:33
  • In Scala-3, Tuple is a generic representation for a tuple with 0 or more elements, so no more parenthesis are needed. I'm hoping to create genericF to be able to handle a Tuple of any size, not just the size of 2 shown in the example. – Travis Stevens Oct 13 '20 at 17:36
  • 1
    @TravisStevens Parenthesis are not needed in method parameters: you can call `extract(Put("Bob"), Put(42))` or `extract((Put("Bob"), Put(42)))`. But types `(String,Int) => Person` and `((String,Int)) => Person` are different. – Dmytro Mitin Oct 13 '20 at 18:26

1 Answers1

6

Your code is almost compiling already - the only thing is that fPerson is of type (String, Int) => Person instead of ((String, Int)) => Person (taking a tuple instead of 2 separate parameters).

The solution below this one is not nice, although it is perhaps more efficient for TupleXXL's. Here's a nicer version with typeclasses (Scastie):

val fPerson: ((String, Int)) => Person = Person.apply _

opaque type Extract[GT <: Tuple, RT <: Tuple] = GT => RT
given Extract[EmptyTuple, EmptyTuple] = Predef.identity
given [A, PG <: Tuple, PR <: Tuple](using p: Extract[PG, PR])
   as Extract[Get[A] *: PG, A *: PR] = {
  case h *: t => h.get *: p(t)
}

def genericF[GT <: Tuple, RT <: Tuple, B](
    f: RT => B,
    t: GT
)(using extract: Extract[GT, RT]): Get[B] = Put(f(extract(t)))

Here's one way you could implement genericF using Tuple.InverseMap (note that I switched the two parameters to genericF:

val fPerson: ((String, Int)) => Person = Person.apply _

type ExtractG = [G] =>> G match {
  case Get[a] => a
}

type AllGs[T <: Tuple] = T match {
  case EmptyTuple => DummyImplicit
  case Get[_] *: t => AllGs[t]
  case _ => Nothing
}

def extract[T <: Tuple](t: T)(using AllGs[T]): Tuple.InverseMap[T, Get] =
  t.map {
    [G] => (g: G) => g.asInstanceOf[Get[_]].get.asInstanceOf[ExtractG[G]]
  }.asInstanceOf[Tuple.InverseMap[T, Get]]

def genericF[B](
    t: Tuple,
    f: Tuple.InverseMap[t.type, Get] => B
)(using AllGs[t.type]): Get[B] = Put(f(extract(t)))

val person: Get[Person] = genericF(t, fPerson)

ExtractG is to make the PolyFunction compile, because it requires you apply a type constructor to its type parameter.

AllGs is to verify that the tuple consists only of Gets, because as pointed out by Dmytro Mitin, it isn't typesafe otherwise. If it's all Gets, the type becomes DummyImplicit, which Scala provides for us. Otherwise, it's Nothing. I guess it could conflict with other implicit/given Nothings in scope, but if you do have one already, you're screwed anyways.

Note that this will work only when you have Get and will need some modification if you also want it to work for tuples like (Put[String], GetSubclass[Int]).


Travis Stevens, the OP, has managed to get the solution above this one to work without creating AllGs, by using IsMappedBy. This is what they got (Scastie):

val fPerson: ((String, Int)) => Person = Person.apply _

type ExtractG = [G] =>> G match {
  case Get[a] => a
}

def extract[T <: Tuple, I <: Tuple.InverseMap[T, Get]](
    t: T
  )(using Tuple.IsMappedBy[Get][T]): I =
  t.map {
    [G] => (g: G) => g.asInstanceOf[Get[_]].get.asInstanceOf[ExtractG[G]]
  }.asInstanceOf[I]

def genericF[T <: Tuple, I <: Tuple.InverseMap[T, Get], B](
    t: T,
    f: I => B
)(using Tuple.IsMappedBy[Get][T]): Get[B] = Put(f(extract(t)))

And here's one using dependent types, just for fun (Scastie):

type Extract[T <: Tuple] <: Tuple = T match {
  case EmptyTuple => EmptyTuple
  case Get[a] *: t => a *: Extract[t]
}
 
type AllGs[T <: Tuple] = T match {
  case EmptyTuple => DummyImplicit
  case Get[_] *: t => AllGs[t]
  case _ => Nothing
}

def genericF[T <: Tuple : AllGs, B](
    t: T,
    f: Extract[t.type] => B
): Get[B] = {
  def extract[T <: Tuple](t: T): Extract[T] = t match {
    case _: EmptyTuple => EmptyTuple
    case (head *: tail): (Get[_] *: _) => head.get *: extract(tail)
  }
  Put(f(extract(t)))
}

I was hoping Extract wouldn't compile for tuples like (Put("foo"), 3), but unfortunately, AllGs is still necessary.

user
  • 7,435
  • 3
  • 14
  • 44
  • 1
    `extract` is not type-safe. If we call `extract((1, 2))` it will compile but throw `ClassCastException` at runtime. – Dmytro Mitin Oct 13 '20 at 18:16
  • 1
    @DmytroMitin How about now? Probably abusing `DummyImplicit`, but it appears to work – user Oct 13 '20 at 18:33
  • 1
    Pretty inventively :) I'm a little puzzled that we use advanced type machinery and still have to use a lot of `asInstanceOf`. – Dmytro Mitin Oct 13 '20 at 18:47
  • 2
    Just throwing this out there as I'm still working through this, but would IsMappedBy be a replacement for AllGs? – Travis Stevens Oct 13 '20 at 19:03
  • 1
    @user Yeah, good old `Comapped` + `NatTRel` :) https://github.com/milessabin/shapeless/blob/master/core/src/main/scala/shapeless/ops/hlists.scala#L91-L156 – Dmytro Mitin Oct 13 '20 at 19:35
  • @TravisStevens I [tried](https://scastie.scala-lang.org/DmkzRNkFT0u82dhInKM15g) with IsMappedBy but had to explicitly provide type arguments for `genericF`. Perhaps you'll have more luck. – user Oct 13 '20 at 19:47
  • 1
    @user I was able get your example to work with IsMappedBy by making the `Tuple.InverseMap[T, Get]` a type parameter [here](https://scastie.scala-lang.org/OleTraveler/1RsAVBMkRJmHX8XE4rb7bg/2) . – Travis Stevens Oct 14 '20 at 15:19
  • @TravisStevens Nice! Mind if I add that to my answer? – user Oct 14 '20 at 15:23
  • 1
    @user InverseMap[T,Get] only works if the Tuple is down cast to a Tuple of Gets eg. `(Get[String], Get[Int])` like they are in your example. InverseMap does not work if we keep the original types: `(Put[String], Put[Int])`. However, using the AllGs approach presented, does indeed work. For my use case, it is vital to keep the original types, so in my project I am using the equivalent to AllGs. – Travis Stevens Oct 14 '20 at 20:29