Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add transparent methods - untyped trees version #4616

Merged
merged 62 commits into from
Jul 26, 2018

Conversation

odersky
Copy link
Contributor

@odersky odersky commented Jun 3, 2018

The bodies of transparent methods are inlined as untyped trees that close over the environment in which they were defined.

This is the fundamental mechanism that allows new types to be computed during type checking and thereby enables type-level programming.

Copy link
Member

@dottybot dottybot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hello, and thank you for opening this PR! 🎉

All contributors have signed the CLA, thank you! ❤️

Commit Messages

We want to keep history, but for that to actually be useful we have
some rules on how to format our commit messages (relevant xkcd).

Please stick to these guidelines for commit messages:

  1. Separate subject from body with a blank line
  2. When fixing an issue, start your commit message with Fix #<ISSUE-NBR>:
  3. Limit the subject line to 72 characters
  4. Capitalize the subject line
  5. Do not end the subject line with a period
  6. Use the imperative mood in the subject line ("Add" instead of "Added")
  7. Wrap the body at 80 characters
  8. Use the body to explain what and why vs. how

adapted from https://chris.beams.io/posts/git-commit

Have an awesome day! ☀️

@odersky
Copy link
Contributor Author

odersky commented Jun 3, 2018

Based on #4589.

@odersky odersky force-pushed the add-transparent branch from 469abd2 to e4080ba Compare June 3, 2018 16:55
@odersky odersky removed the stat:wip label Jun 3, 2018
@odersky
Copy link
Contributor Author

odersky commented Jun 3, 2018

test performance please

@dottybot
Copy link
Member

dottybot commented Jun 3, 2018

performance test scheduled: 1 job(s) in queue, 0 running.

@dottybot
Copy link
Member

dottybot commented Jun 3, 2018

Performance test finished successfully:

Visit http://dotty-bench.epfl.ch/4616/ to see the changes.

Benchmarks is based on merging with master (228b232)

@milessabin
Copy link
Contributor

Can we make this work for function values too?

@odersky
Copy link
Contributor Author

odersky commented Jun 4, 2018

@nicolasstucki, @allanrenucci can you help find out what's wrong here? If I run the command it issues to reproduce manually everything looks fine.

@odersky
Copy link
Contributor Author

odersky commented Jun 4, 2018

@milessabin Do you mean a transparent val of function type is beta-reduced as well? Could be possible. Do you have a use case in mind?

@odersky
Copy link
Contributor Author

odersky commented Jun 6, 2018

For the time being put on hold in favor of #4622.

@odersky odersky closed this Jun 6, 2018
@odersky odersky changed the title Add transparent methods Add transparent methods - untyped trees version Jun 15, 2018
@odersky odersky reopened this Jun 15, 2018
@odersky
Copy link
Contributor Author

odersky commented Jun 15, 2018

I am reviving this PR as a possible alternative to the TypeOf approach, to be discussed further.

To start the deliberations here are some use cases:

  1. Classical typelevel programming: heterogenous lists, hmaps, deriving sums-of-products using a fold-based typeclass derivations
  2. Optimizations a la Spire or Boost. Specialize structures given some type and term parameters to have an optimized representation.
  3. Dependently typed programming, e.g. Vector[T](size)

All approaches have the following principle in common: We have a typed term that gets reduced and simplified at typer-time. The type of the term is then the type of its redux. That type can be more precise than the type of the original term before reduction. The question is how to achieve this type narrowing. If we reduce the fully-typed term, we will often get a type that is too coarse. Factors that prevent narrowing are:

  1. Type arguments are inferred and then form part of the typed term, so cannot change anymore.
  2. Types of local definitions are inferred and then form part of the typed term, so cannot change anymore.
  3. Overloading is resolved before specialization, cannot adapt anymore to more precise types.
  4. Implicit resolution is done before specialization, cannot adapt anymore to more precise types.

We need a way to sidestep (1) and (2). Solving (3) and (4) is needed for optimizing applications such as Spire, for the other use case re-resolving overloading and implicit parameters is probably not needed, and might even be considered problematic.

Here's a breakdown of the considered approaches:

First approach: Reduce open terms (This PR) When calling a transparent method, we inline conceptually the method body as an untyped tree that closes over the original environment where the method was created. According to Connor McBride there seems to be precedent for that: http://www.cs.nott.ac.uk/~psztxa/publ/ctcs95.pdf

Pros and cons:

+ This guarantees narrowings (1-4) by design, since everything is re-typed. Problems like variable capture or lack of hygiene are avoided by typing the term in its original environment.

+ All use cases are supported by a single mechanism.

- Retyping the term risks introducing type errors e.g. picking a different overload might lead to a type error later on. Or, over-specializing the accumulator of a fold might render the fold untypable. Such errors have to be communicated clearly, and we need to make sure empirically that they occur not too frequently.

- We have to be able to pickle and unpickle untyped trees, which adds complexity.

- We have to compromise on the theoretical model when it comes to implicit search. The way to capture the environment for implicit search is to make all implicit references in the original term available as implicit bindings at the expansion side. But that makes it still possible that other implicit queries that originate from narrowed types are resolved against the expansion environment. So that means that the untyped term is really re-typechecked with a combination of the definition environment, nested inside the expansion environment. It's not impossible to do so, but it might look a bit strange in a formalization.

Second approach: TypeOf (latest status in #4671) We introduce a special type T @TypeOf(t) that represents the type of term t, upper bounded by T. When calculating the type of an application of a transparent method app, we replace the TypeOf type by the result type of re-typechecking the expansion of app. We need to be careful to propagate these types in the right way. For instance the type of an if-then-else cannot be the lub of the types of its branches, as is done usually. Instead it would have to be a dependent type that, depending on the condition selects one or the other branch type. Or else we avoid the complication by defining that any term containing a transparent method call will get a TypeOf type, so that we force its re-typing on expansion.

Pros and cons:

+ We have fine grained control what things are re-done on expansion. For instance we could ensure that overload resolution and implicit arguments will stay the same.

- It looks like we cannot redo overloading resolution or implicit search, so the optimization use case is out of reach.
- It could be complex to guarantee that all type maps are applied correctly to terms.
- It's potentially very costly to typecheck all the terms in the TypeOf types. It looks like a quadratic problem: Every subterm has another TypeOf type which largely shares the tree with the type of its children and parent. We need a way to exploit sharing to avoid the quadratic factor this implies.
- It's hard to keep terms in TypeOf and terms in the tree in sync under typemaps. To keep sharing, we have to deal with graphs instead of trees, and there is currently no infrastructure for that.
- It's not quite clear what these things mean theoretically.

Third approach: Lifting (not yet done) We bite the bullet and lift everything to the type level. So there are type-level ifs, matches, applications, local definitions and so on. We can then just use straight typelevel computation to produce the result types.

Pros and cons:

+ This is well understood in principle, although details need to be worked out.

- This causes a lot of duplication between term level and type level.
- It risks supporting not all of the term language (because of the complexity of doing so),
which could lead to arbitrary restrictions what is supported and what is not.
- It does not address the problem of generating specialized and optimized terms.

Note: We are effectively doing a form of partial evaluation on typed terms here. The following paper by John Hughes looks relevant: http://www.cse.chalmers.se/~rjmh/Papers/typed-pe.html.

@OlivierBlanvillain
Copy link
Contributor

I will try to quickly summarize what we discussed yesterday with @gsps.

First of all, I think optimizations a la Spire or Boost should be kept out of the scope of transparent methods. I think dependent types are a big enough challenge to be addressed in isolation, and optimizations are better addressed either automatically by an optimizing backed-end (as done in Scala.js and Scala Native) or exposed to the user as done with staging.

It seems that neither Dependent Haskell neither Idris share code between their type checker and their optimizer. Idris has an interesting mechanism called static arguments, it's an annotation on function arguments that indicates that this function should be specialized at every call site and partially evaluated for statically known values of these arguments.

From a library user perspective, the main pain points about the current implicits + macro encoding of dependent types is compilation time and size of the generated code, both of which can very quickly go out of hand. I feel that by specialize every dependent function at call site would lead to the same issue of unpredictable bytecode size and compilation time.

So to conclude this discussion on the scope, I think both @gsps and I tend towards the type only approach where trees are left alone as written by the user. What you called Third approach: Lifting corresponds to what's done in other languages and what we would like to try in Scala.

I answered to the parts specific to TypeOf in #4671.

@odersky
Copy link
Contributor Author

odersky commented Jun 20, 2018

Idris has an interesting mechanism called static arguments, it's an annotation on function arguments that indicates that this function should be specialized at every call site and partially evaluated for statically known values of these arguments.

Idris can do this because it does everything on the term level. Types are just terms in Idris, after all. So the static arguments feature and dependent type specialization probably use the same underlying mechanism - I would be surprised if this was not the case!

So to conclude this discussion on the scope, I think both @gsps and I tend towards the type only approach where trees are left alone as written by the user. What you called Third approach: Lifting corresponds to what's done in other languages and what we would like to try in Scala.

So that means we should discontinue the TypeOf annotation approach? That would narrow it down to two choices, which is progress.

@odersky
Copy link
Contributor Author

odersky commented Jun 20, 2018

I feel that by specialize every dependent function at call site would lead to the same issue of unpredictable bytecode size and compilation time.

In principle, inlining leads to the same asymptotic code size behavior as implicit search. But there are two differences:

  1. The constant factors are much smaller. For instance here is the code for the computation of concat in:
  val xs = 1 :: "a" :: "b" :: HNil
  val ys = true :: 1.0 :: HNil
  val zs = concat(xs, ys)

Expansion:

    val zs: Int :: String :: String :: Boolean :: Double :: HNil$ = 
      {
        new ::[Int, String :: String :: Boolean :: Double :: HNil$](Test.xs.head
          , 
        {
          val xs: String :: String :: HNil$ = Test.xs.tail
          new ::[String, String :: Boolean :: Double :: HNil$](xs.head, 
            {
              val xs: String :: HNil$ = xs.tail
              new ::[String, Boolean :: Double :: HNil$](xs.head, 
                {
                  val xs: HNil$ = xs.tail
                  Test.ys
                }
              )
            }
          )
        }
        )
      }

For an expanded version this code is optimal and much, much simpler than the equivalent implicit argument.

  1. You can write your definitions so that specialization is avoided or that it happens only up to a certain size. This requires the ability to compute types instead of terms, which is planned as a next step. If you can do that, you can define a non-transparent method like
   def concat[Xs <: HList, Ys <: HList](xs: Xs, ys: Ys): Concat[Xs, Ys] = 
     /* code as before */ .asInstanceOf[Concat[Xs, Ys]]

Or you can expand up to a certain size and fallback to the runtime method afterwards.

So in the end it is a matter of what the default is. In the first case, inlining is the default but can be turned off, in the second case, type-specializing inlining is not possible, or requires a separate mechanism.

@milessabin
Copy link
Contributor

milessabin commented Jun 21, 2018

@odersky is this scheme intended to support things like,

transparent def map(xs: HList): HList = {
  def mapHead[T, R](t: T)(implicit fh: T => R): R = fh(t)

  if (xs.isEmpty) HNil
  else HCons(mapHead(xs.head), map(xs.tail))
}

implicit val mapInt: Int => Boolean = (i: Int) => i < 23
implicit val mapString: String => Int = (s: String) => s.length
implicit val mapBoolean: Boolean => String = (b: Boolean) => if(b) "yes" else "no"

map(HCons(23, HCons("foo", HCons(true, HNil))))

// yields HCons(false, HCons(3, HCons("yes", HNil)))

Also,

  • Is the transparent modifier applicable to any method? Implicit methods? Dependent methods? Constructors? Nested methods like mapHead above?
  • Seeing as we're trying to align method and function types, should there be transparent functions?

@milessabin
Copy link
Contributor

I'm also wondering if this can capture the sort of recursive knot-tying scenarios which we currently handle with Lazy/byname implicits?

@OlivierBlanvillain
Copy link
Contributor

OlivierBlanvillain commented Jun 21, 2018

@odersky

So the static arguments feature and dependent type specialization probably use the same underlying mechanism

What I was trying to get at is that it's not a given that we should be specializing everything, regardless of implementation (same mechanism or not). I'll try to back up my guess with an example, but I would be surprised if Idris unconditionally specializes function calls.

So that means we should discontinue the TypeOf annotation approach? That would narrow it down to two choices, which is progress.

I think it's nice to have a single type to talk about all lifted trees (and it should probably replace contant types & co at some point), but I don't see what we have to gain using the annotation infrastructure. If you look at the diff of #4616 you will see that we had to special case almost all treatments of AnnotatedTypes for TypeOfs.

You can write your definitions so that specialization is avoided or that it happens only up to a certain size. This requires the ability to compute types instead of terms, which is planned as a next step.

Why consider type functions as a next step when we can express everything as term functions? Your example could be implemented with TypeOfs as follows:

def concat[Xs <: HList, Ys <: HList](xs: Xs, ys: Ys): { concatType(xs, ys) } =
  // Run time implementation

transparent def concatType(xs: HList, ys: HList): HList =
  // Compile time implementation

So in the end it is a matter of what the default is. In the first case, inlining is the default but can be turned off, in the second case, type-specializing inlining is not possible, or requires a separate mechanism.

It's a design choice indeed, but do we solve anything by making specialization the default vs delegating that to say, staging? The main issue people have today with Circe, for example, is compile time and size of the generated code, not runtime performance.

@OlivierBlanvillain
Copy link
Contributor

@milessabin

Is the transparent modifier applicable to any method? Implicit methods? Dependent methods? Constructors? Nested methods like mapHead above? Seeing as we're trying to align method and function types, should there be transparent functions?

Definetly for implicit methods, constructors and nested methods. Functions seems like a natural thing, we could probably add that later.

On your mapHead example, I think there is nice way to encode it using implicit function types. Something like the following:

transparent def map(xs: HList): HList =
  xs match {
    case HNil => HNil
    case (x: Head) :: xs => {
      implicit fh: Function1[Head, Any] => // To Any I guess?
        magic(map(xs)) { tail =>
          HCons(fh(x), tail)
        }
    }
  }
}

Where magic takes care of mapping over a curried implicit functions.

I'm also wondering if this can capture the sort of recursive knot-tying scenarios which we currently handle with Lazy/byname implicits?

I would be very tempted to make everything strict in the types. I've read that Haskell/Dependent Haskell is quite messy mixing the two...

@milessabin
Copy link
Contributor

@OlivierBlanvillain in your example you have the comment // To Any I guess? ... no, definitely not to Any ... what I was aiming for was a minimal version of shapeless's Poly, the three implicit functions are distinct cases for the different argument types, yielding differently typed result. With shapeless, mapping a similar Poly over an HList of type Int :: String :: Boolean :: HNil would yield a result of type Boolean :: Int :: String :: HNil.

I'm hoping that my example above would yield a similarly typed result. I'm pretty sure your example with an implicit function type wouldn't ... is there some way that it could be fixed up so that it does?

@milessabin
Copy link
Contributor

milessabin commented Jun 21, 2018

@OlivierBlanvillain I can't say that I'm very happy with the type and term level duplication in this example,

def concat[Xs <: HList, Ys <: HList](xs: Xs, ys: Ys): { concatType(xs, ys) } =
  // Run time implementation

transparent def concatType(xs: HList, ys: HList): HList =
  // Compile time implementation

I think it's inevitable that there will be some duplication, but I think it ought to be possible to avoid having to completely replicate the entire function signature in that way.

@gsps
Copy link
Contributor

gsps commented Jun 21, 2018

@milessabin

With shapeless, mapping a similar Poly over an HList of type Int :: String :: Boolean :: HNil would yield a result of type Boolean :: Int :: String :: HNil.
That was certainly the intent of the above example with IFTs -- the Any is just there for lack of something more concrete to match on, and should work out fine thanks to Function1[-T1, +R] being covariant in R. You'd resolve the three distinct implicit vals via the Head parameter, and widen each of those functions' result types to Any; that's the idea anyways.

The slightly tricky part is that magic function which essentially does function composition over IFTs while aggregating the implicit parameters. It'd be nice to (predictably) get rid of those additional lambdas though. We do already have precedent for this with IFTs eta-expanding and those lambdas being taken care of in ShorcutImplicits.

Ad the concat example

I can't say that I'm very happy with the type and term level duplication in this example (...)

Agreed. The corresponding Concat type function would presumably also duplicate most of the signature, though. Whatever the solution, I can't help but think that it'd be safer to make any code generation opt-in rather than opt-out via type ascriptions.

@odersky
Copy link
Contributor Author

odersky commented Jun 22, 2018

Is the transparent modifier applicable to any method? Implicit methods? Dependent methods? Constructors? Nested methods like mapHead above?

Yes, for implicit and dependent and nested methods. Constructors: This looks difficult. Definitely not primary constructors.

@milessabin
Copy link
Contributor

@Blaisorblade @odersky granted there might be problems with my variant, but yours don't work either ... try applying your concats to some terms and you'll see them fail, given what's currently on master.

At the moment I can't see a fully working example of any form, so it's quite difficult to firm up my intuitions about how this stuff is supposed to work.

@milessabin
Copy link
Contributor

top-level matches must be reduced at compile time, but that’s impossible if the arguments are hidden behind an abstraction boundary.

By "abstraction boundary" do you mean Vec[N, A] as the argument type of concat rather than VNil or VCons? If that's the case then I don't see how your or Martin's version are supposed to work either.

concat needs transparent params

Given the inlining semantics, surely the parameters of transparent methods are "effectively" transparent already: isn't it the case that the expansion will see the unwidened type from the call site?

@milessabin
Copy link
Contributor

Ahh ... light dawns finally ... the abstraction boundary is the typing of tl in VCons.

This variant works as intended,

sealed trait Nat
case object Z extends Nat
case class S[N <: Nat] extends Nat

object Nat {
  type Z = Z.type
}

import Nat.Z

sealed trait Vec[N <: Nat, +A]
case object VNil extends Vec[Z, Nothing]
case class VCons[NT <: Nat, +A, TL <: Vec[NT, A]](hd: A, tl: TL) extends Vec[S[NT], A]

object Vec {
  type VNil = VNil.type
}

import Vec.VNil

object Concat {
  transparent def concat[A](xs: Vec[_, A], ys: Vec[_, A]): Vec[_, A] = 
    xs match {
      case VNil => ys
      case VCons(hd, tl) => VCons(hd, concat(tl, ys))
    }
}

import Concat.concat

object Test {
  val v1 = VCons(1, VCons(2, VNil))
  val v2 = VCons(3, VCons(4, VCons(5, VNil)))

  val v3 = concat(v1, v2)
}

@milessabin
Copy link
Contributor

This clarifies nicely for me that it's the type matching functionality of implicit search which is doing most of the work in existing shapeless-style typelevel computations ... and if we have an alternative mechanism for doing that, then the additional overhead of implicit search is unnecessary.

On the other hand, it appears that we do lose something relative to implicit induction: by declaring the recursion up front as part of the function signature, implicit inductions are able to express relationships between argument and result types in ways that don't appear to be possible (or is at least more involved) with transparent methods.

It would be nice to see if we can close the gap somehow.

@odersky
Copy link
Contributor Author

odersky commented Jul 28, 2018

@odersky @Blaisorblade I'm afraid that neither of your examples work for me: they compile as written but if you attempt to apply your concat's to any Vec values they fail with compilation errors at the expansion site.

I just tried, this, which works as I expected:

  transparent def add(x: Nat, y: Nat): Nat = x match {
    case Z => y
    case S(x1) => S(add(x1, y))
  }

  transparent def concat[T, N1 <: Nat, N2 <: Nat](xs: Vec[T, N1], ys: Vec[T, N2]): Vec[T, _] = {
    erased val length = Typed(add(erasedValue[N1], erasedValue[N2]))
    Vec[T, length.Type](xs.elems ++ ys.elems)
  }

  val xs = Vec[Int, S[S[Z]]](List(1, 2))
  val ys = Vec[Int, S[Z]](List(3))
  val zs = concat(xs, ys)
  val zsc: Vec[Int, S[S[S[Z]]]] = zs

@odersky
Copy link
Contributor Author

odersky commented Jul 28, 2018

It would be nice to see if we can close the gap somehow.

I agree. But before we embark on this, I want to see how far we can push things with Typed and erasedValue alone. It looks like they give you considerable power to link the type and the term world.

@odersky
Copy link
Contributor Author

odersky commented Jul 28, 2018

Btw, do you also find the following annoying?

object Vec {
  type VNil = VNil.type
}

It looks to me a bit awkward to have to do this. Should we try to make this redundant by declaring that a companion-less [EDIT: case] object implicitly defines this alias? Has this idea been discussed before?

@soronpo
Copy link
Contributor

soronpo commented Jul 28, 2018

Has this idea been discussed before?

I once suggested on the scala contributors gitter that if we write type object VNil, it will do just that. The idea was rejected out of hand. If just object defines the alias, the question is what happens when there is an alias in scope.

@milessabin
Copy link
Contributor

milessabin commented Jul 28, 2018

@odersky ahh ... an extra erased. That's missing, along with the instantiation, in tests/pos/typelevel-vector1.scala.

I'm still not entirely clear when additional erased annotations like the above are and aren't needed.

@milessabin
Copy link
Contributor

Btw, do you also find the following annoying?

Yes I do.

Should we try to make this redundant by declaring that a companion-less [EDIT: case] object implicitly defines this alias?

Yes, that might be helpful.

Has this idea been discussed before?

Not that I recall.

@sjrd
Copy link
Member

sjrd commented Jul 28, 2018

Should we try to make this redundant by declaring that a companion-less [EDIT: case] object implicitly defines this alias?

Please don't. That would be a nightmare in terms of library evolution, since as soon as I published a version of my library with an object, I can never add a companion class to it.

@odersky
Copy link
Contributor Author

odersky commented Jul 28, 2018

@sjrd: It would be only for case objects.

One could specify that the alias is generated only if no other type of that name exists (as opposed to class).

@odersky
Copy link
Contributor Author

odersky commented Jul 28, 2018

@milessabin I believe the erased should be redundant on master. I am currently on a branch where I experiment with decoupling transparent and erased and there the erased would be required. If you try without erased, what to you see?

@odersky
Copy link
Contributor Author

odersky commented Jul 28, 2018

I played a bit with @milessabin's vector example. Here's a version that makes length a dependent type instead of a type parameter. It looks a bit cleaner, I find. Just to give another datapoint.

sealed trait Nat
case object Z extends Nat
case class S[N <: Nat] extends Nat

object Nat {
  type Z = Z.type
}

import Nat.Z

sealed trait Vec[+A] { type Len <: Nat }
case object VNil extends Vec[Nothing] { type Len = Z }
case class VCons[+A, TL <: Vec[A]](hd: A, tl: TL) extends Vec[A] { type Len = S[tl.Len]}

object Vec {
  type VNil = VNil.type
}

import Vec.VNil

object Concat {
  transparent def concat[A](xs: Vec[A], ys: Vec[A]): Vec[A] =
    xs match {
      case VNil => ys
      case VCons(hd, tl) => VCons(hd, concat(tl, ys))
    }
}

import Concat.concat

object Test {
  val v1 = VCons(1, VCons(2, VNil))
  val v2 = VCons(3, VCons(4, VCons(5, VNil)))
  val v3 = concat(v1, v2)
  val v3l: v3.Len = S[S[S[S[S[Z]]]]]()
}

@milessabin
Copy link
Contributor

@odersky on master erased is necessary.

@milessabin
Copy link
Contributor

milessabin commented Jul 28, 2018

@odersky I've taken a slightly different approach to giving concat a more informative type. Here I'm returning both the concatenated result, and its length, packaged as a dependent pair.

sealed trait Nat
case object Z extends Nat
case class S[N <: Nat](n: N) extends Nat

object Nat {
  type Z = Z.type
}

import Nat.Z

sealed trait Vec[N <: Nat, +A]
case object VNil extends Vec[Z, Nothing]
case class VCons[NT <: Nat, +A, TL <: Vec[NT, A]](hd: A, tl: TL) extends Vec[S[NT], A]

object Vec {
  type VNil = VNil.type
}

import Vec.VNil

trait Concat[N1 <: Nat, N2 <: Nat, A] { outer =>
  type Sum <: Nat
  val result: Vec[Sum, A]

  transparent def cons(hd: A) =
    new Concat[S[N1], N2, A] {
      type Sum = S[outer.Sum]
      val result = VCons(hd, outer.result)
    }
}

object Concat {
  transparent def concat[N1 <: Nat, N2 <: Nat, A](xs: Vec[N1, A], ys: Vec[N2, A]): Concat[N1, N2, A] = 
    xs match {
      case VNil => new Concat[Z, N2, A] { type Sum = N2 ; val result = ys }
      case VCons(hd, tl) => concat(tl, ys).cons(hd)
    }
}

import Concat.concat

object Test {
  val v1 = VCons(1, VCons(2, VNil))
  val v2 = VCons(3, VCons(4, VCons(5, VNil)))

  val v3 = concat(v1, v2)
  val v3c: Concat[S[S[Z]], S[S[S[Z]]], Int] { type Sum = S[S[S[S[S[Z]]]]] } = v3
  val v3r: Vec[S[S[S[S[S[Z]]]]], Int] = v3.result
}

This provides stronger correctness guarantees about the implementation than your version. For instance, if in yours you change the VNil case of concat to return VNil rather than ys then concat will still compile and v3 will be computed but have the wrong type.

See retraction below: The corresponding change in this version would be to have the VNil case return new Concat[Z, Z, A] { type Sum = Z ; val result = VNil } and with this change concat doesn't typecheck.

Although this approach is a bit more cumbersome, I think it might generalize quite nicely. For instance rather than returning just the length we could return a more explicit witness that N1+N2 = Sum.

Maybe we could come up with syntax that makes it a bit easier on the eye?

@milessabin
Copy link
Contributor

I think my earlier confusion about top level matching is interesting ... if we have,

sealed trait Vec[N <: Nat, +A]
case object VNil extends Vec[Z, Nothing]
case class VCons[NT <: Nat, +A](hd: A, tl: Vec[NT, A]) extends Vec[S[NT], A]

why isn't a top level match of the following form reducible?

xs match {
  case VNil => ...
  case VCons(hd, tl) => ...
}

What information are we missing at compile time? How does this differ from normal GADT pattern matching?

@milessabin
Copy link
Contributor

The corresponding change in this version would be to have the VNil case return new Concat[Z, Z, A] { type Sum = Z ; val result = VNil } and with this change concat doesn't typecheck.

I'm overclaiming here. If the VNil case is changed to new Concat[Z, N2, A] { type Sum = Z ; val result = VNil } then my version will also compile and v3 will be computed with the wrong type.

@smarter
Copy link
Member

smarter commented Jul 28, 2018

This provides stronger correctness guarantees about the implementation than your version. For instance, if in yours you change the VNil case of concat to return VNil rather than ys then concat will still compile and v3 will be computed but have the wrong type.

I think this could also be achieved in Martin's implementation if we could somehow write:

  transparent def concat[A](xs: Vec[A], ys: Vec[A]): Vec[A] { type Len = { plus[xs.Len, ys.Len] } } =
    xs match {
      case VNil => ys
      case VCons(hd, tl) => VCons(hd, concat(tl, ys))
    }

This might be achievable with AppliedTermRef

@milessabin
Copy link
Contributor

I'd very much like to see the AppliedTermRef stuff go in.

@Blaisorblade
Copy link
Contributor

@smarter That's why I also asked about AppliedTermRef (in our Wednesday meeting this week), but Martin objected with an important technical issue, which was mentioned a couple times here and which makes that pretty hard — basically, it'd be full dependent Scala, which needs to be approached carefully. I take his word on Dotty itself, but I can attest myself the theory is hairy, and for reasons that would probably affect an implementation even if it didn't aim for soundness.

To make it clearer, AppliedTermRef introduces more type equalities, for instance { plus[Z, ys.Len] } = ys.Len. Then, each type transformer f in Dotty, must guarantee that f({ plus[Z, ys.Len] }) = f(ys.Len). Otherwise, different but equivalent types would become incompatible after Dotty transforms the program using f.
This guarantee in known under different names; Martin writes upthread that "rewritings must be stable under substitutions" (and that's hard), and I wrote that " all TypeTransformer must commute with such reduction rules". The same sort of guarantee tends to also matter in proofs.

Conversely, allowing AppliedTermRef on constructors, such as in { S(Z) } has no such problem, since those types are already in normal form.

Implicit search through transparent functions, and closed type families

Unlike equalities given by term functions, existing type equalities for type members are already handled by the typechecker, and @milessabin last example with the Sum type member exploits that! Then the question is simply what syntax to offer on top of it.

Going back to something like

transparent def concat[T, N1 <: Nat, N2 <: Nat](xs: Vec[T, N1], ys: Vec[T, N2])
  (implicit add: Add[N1, N2]): Vec[T, add.Result] =...

Maybe we could allow add to be generated by a transparent function instead of implicit search, in a way that encodes closed type functions? Supporting that properly for GADT-style matches might still be tricky, since Dotty's GADT machinery still has some open questions — tho of course you can always use casts in the implementation (not perfect, but using GADTs in Scalac is not much better).

@odersky
Copy link
Contributor Author

odersky commented Jul 28, 2018

@milessabin OK, good to know!

@odersky
Copy link
Contributor Author

odersky commented Jul 29, 2018

@milessabin why isn't a top level match of the following form reducible?

xs match {
  case VNil => ...
  case VCons(hd, tl) => ...
}

It might well be a shortcoming of the current implementation. I believe there are several ways to improve the match reducer, in particular in what concerns GADTs.

@olafurpg
Copy link
Contributor

olafurpg commented Aug 8, 2018

I experimented with the latest Dotty nightly build (0.10.0-bin-20180807-84966d2-NIGHTLY) to see how the recently added Product.productElementName (scala/scala#6972) could interact with transparent methods and I was pleasantly surprised to find out it mostly works as expected!

  case class User(name: String, age: Int) {
    def elementName(i: Int): String = i match {
      case 0 => "name"
      case 1 => "age"
      case _ => throw new IndexOutOfBoundsException(i.toString)
    }
    transparent def element(i: Int): Any = i match {
      case 0 => name
      case 1 => age
      case _ => throw new IndexOutOfBoundsException(i.toString)
    }
  }

  transparent def toTuple(u: User, i: Int): Any = i match {
    case 2 => ()
    case n => ((u.elementName(n), u.element(n)), toTuple(u, n + 1))
  }

  def main(args: Array[String]): Unit = {
    val user = User("Susan", 42)
    val x = toTuple(user, 0) // static type: ((String, String), ((String, Int), Unit))
    println(x) // output: ((name,42),((age,Susan),()))
  }

One surprising behavior was that replacing toTuple(user, 0) with toTuple(user, -1) succeeds compilation but yields IndexOutOfBoundsException: -1 at runtime. I expected throw new IndexOutOfBounds(n.toString) to fail compilation while trying to inline the transparent element method.

@Blaisorblade
Copy link
Contributor

@olafurpg The exception must be thrown at runtime, since transparent just means “inline and simplify”, but see #4863. OTOH, toTuple(user, i) should fail in a non-transparent method.

@olafurpg
Copy link
Contributor

olafurpg commented Aug 9, 2018

@Blaisorblade I think #4863 is unrelated. What I'm missing is the ability to tell compilation to fail if a program simplifies to a "bad branch"

transparent def divide(a: Int, b: Int): Int = b match {
  case 0 => throw DivideByZero // fail compilation
  case _ => a / b
}
divide(10, 0) // compile error

I can't imagine a scenario where I want an expression to statically simplify into a throw clause.

@olafurpg
Copy link
Contributor

olafurpg commented Aug 9, 2018

This functionality is similar to what @LPTK describes in #4863 (comment) but I'm not sure what that issue is about.

@Blaisorblade
Copy link
Contributor

@olafurpg right, I meant to refer to that comment.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.