fthomas / singleton-ops

Operations for primitive and String singleton types
Apache License 2.0
163 stars 20 forks source link

support a rational type #140

Open erikerlandson opened 4 years ago

erikerlandson commented 4 years ago

Libra does this. I've wanted it in coulomb for a while (https://github.com/erikerlandson/coulomb/issues/83). I could just port the code, but it seems like it might be a good fit for abstracting into singleton-ops, if stakeholders are agreeable.

soronpo commented 4 years ago

Can you point me to the relevant code?

erikerlandson commented 4 years ago

@soronpo it's here:

https://github.com/to-ithaca/libra/blob/master/core/src/main/scala/libra/Fraction.scala

https://github.com/to-ithaca/libra/blob/master/core/src/main/scala/libra/ops/fraction.scala

soronpo commented 4 years ago

I think it's worth doing, but perhaps in a different way. I like that singleton-ops gives the "feel" of similar term algebraic behavior. The problem is that if we try to do in types Frac1 + Frac2 it will not work and we need to directly invoke fraction.Add[Frac1, Frac2]. The reason this doesn't work as-is is because the singleton-ops macro handles all (the known) possible operator variations since we don't have polymorphic calls to 'type operations'. Can we do better? Can we make things more generic? I believe so.

We can change it so that when a macro finds an unsupported situation, e.g. +[Frac, Frac], it will try to invoke an implicit of that type signature. Since the output of that operation is also a Frac, the result will be CalcUnknown. We may need to require a little more additional machinery, but that's no biggy I think.

So essentially, if we do this right, the type classes will be defined for the current op types (other than the new unique operations for Frac). I think new operations should use the same mechanism with new Op Ids.

soronpo commented 4 years ago

Let me clarify further. If we do +[Frac1, Frac2] and in scope you already have implicit def fracAdd[Frac1 <: Frac[_,_], Frac2 <: Frac[_,_]] : Frac1 + Frac2 = ??? then this fracAdd will be called because it's a more specific signature. However, once you have a composition Frac1 + Frac2 + Frac3, then the macro will be called. So for this case we need to change the macro as I have stated above.

soronpo commented 4 years ago

Perhaps to avoid implicit recursion we should add an AlternativeOp typeclass with the same signature as OpMacro, so the macro with call it instead.

erikerlandson commented 4 years ago

I think we can probably make that work. I've had problems with inferImplicitValue in the past, although only in some situations: https://stackoverflow.com/questions/43097790/scala-syntax-tree-returned-from-inferimplicitvalue-is-failing-to-evaluate

If it was 2.13+ only, I'd feel pretty comfortable having implicits that can parse out sub-expressions, but that also is dicey, pre 2.13 (and is the reason coulomb works only on 2.13 plus)

I'm a little hazy on how the internals of the OpMacro code works. Can the new type be detected inside to avoid a CalcUnknown ?

erikerlandson commented 4 years ago

How does this happen now, with existing singleton types? The case of 1 + 2 + 3 seems like it incurs the same basic structural issue, even if the argument types are native singletons.

soronpo commented 4 years ago

I think we can probably make that work. I've had problems with inferImplicitValue in the past, although only in some situations: https://stackoverflow.com/questions/43097790/scala-syntax-tree-returned-from-inferimplicitvalue-is-failing-to-evaluate

If it was 2.13+ only, I'd feel pretty comfortable having implicits that can parse out sub-expressions, but that also is dicey, pre 2.13 (and is the reason coulomb works only on 2.13 plus)

That's because it uses c.eval. We don't need it. It's just something like c.typecheck(q"implicitly[AlternativeOp[$OpTpe, $Arg1Tpe, $Arg2Tpe, $Arg3Tpe]]")

I'm a little hazy on how the internals of the OpMacro code works. Can the new type be detected inside to avoid a CalcUnknown ?

CalcUnknown should be perfectly fine. It just keeps the calculated type as-is since it's not Int, Long, etc... The only thing I'm currently unsure of is what happens when we have something like CalcUnknown + CalcUnknown.

How does this happen now, with existing singleton types? The case of 1 + 2 + 3 seems like it incurs the same basic structural issue, even if the argument types are native singletons.

The macro does not have upper-bounds. It's just one big case for everything. So it traverses the type tree calculates 1 + 2, and takes that calculation to continue on with + 3.

soronpo commented 4 years ago

My current guess is that we just need to add the call to implicitly here: https://github.com/fthomas/singleton-ops/blob/master/src/main/scala/singleton/ops/impl/GeneralMacros.scala#L901 So in case there is no supported calculation, it tries to invoke implicitly with AlternativeOp and if it succeeds, then it reapplies the calculation on it using TypeCalc(alternativeTpe).

erikerlandson commented 4 years ago

Thanks @soronpo! I'll play around with it when I get a chance

soronpo commented 4 years ago

Wow, this is really cool. If this actually works, singleton-ops will make it very easy to define recursive type operations:

  //Needs to be added to singleton-ops
  trait AlternativeOp[N, S1, S2, S3] extends OpMacro[N, S1, S2, S3] {
    type OutWide = None.type
    val valueWide : OutWide = None
    val isLiteral : Boolean = false
  }
  //Fibonacci example
  trait FibId
  type Fib[P] = AlternativeOp[FibId, P, 0, 0]
  implicit def doFib[P](implicit op : ITE[P == 1, 1, ITE[P == 0, 0, Fib[P - 1] + Fib[P - 2]]]) : Fib[P]{type Out = op.Out} = new Fib[P] {
    override type Out = op.Out
    override val value : op.Out = op.value
  }