Introduction
In Scala, we have two possible representations for numbers and strings, either as literal constants or runtime values. SIP23 (implemented in TypeLevel Scala and Dotty) adds literal types support. The refined library enables constraining function arguments, while the singleton-ops library enables constraining and performing operations on literal types. Nat
from shapeless also provides compile-time operation, but it is disregarded in this discussion. While the compiler and libraries offer great flexibility, they may lead to a very verbose code if we wish to support both compile-time and run-time values.
Why support both compile-time and run-time values?
Well, it depends on the use case, but generally it is best to apply compile-time operations and checks when possible, and if not possible, fall-back to run-time operations and checks.
Example: A fixed-sized vector
Let’s assume we are required to implement a fixed-sized vector with a given length
.
-
length
must be positive. - A vector can be concatenated with another vector. The result is a vector with a length as the sum of the two vector lengths.
- A vector can sum its elements with another vector’s elements. The two vectors must have the same length for the operation to work.
We will now explore different implementation options, without the backend implementation of the vector elements.
length
as a run-time integer value
class FixedSizeVector private (val length : Int) {
def concat(that : FixedSizeVector) = FixedSizeVector.create(this.length + that.length)
def +(that : FixedSizeVector) = {
require(this.length == that.length, "lengths must be the same for addition")
FixedSizeVector.create(this.length)
}
}
object FixedSizeVector {
protected def create(length : Int) = new FixedSizeVector(length)
def apply(length : Int) = {
require(length > 0, "length must be positive")
create(length)
}
}
val v5 = FixedSizeVector(5) //works
var four = 4
val v4 = FixedSizeVector(four) //works
val fail1 = FixedSizeVector(-5) //fails at run-time
val v9 = v5 concat v4 //works
val v5_add = v5 + v5 //works
val fail2 = v5 + v4 //fails at run-time
Simple.
Run-time only. Very unsafe (Can only check length
at run-time).
length
as a compile-time literal value
class FixedSizeVector[L <: Int with Singleton] private (val length : L) {
def concat[R <: Int with Singleton](that : FixedSizeVector[R]) = FixedSizeVector.create(this.length + that.length) //COMPILE ERROR!!!
def +(that : FixedSizeVector[L]) = FixedSizeVector.create(this.length)
}
object FixedSizeVector {
protected def create[L <: Int with Singleton](length : L) = new FixedSizeVector(length)
def apply[L <: Int with Singleton](length : L) = {
require(length > 0, "length must be positive")
create(length)
}
}
val v5 = FixedSizeVector(5) //works
var four = 4
val v4 = FixedSizeVector(four) //does not work (run-time value not supported)
val fail1 = FixedSizeVector(-5) //fails at run-time
val v9 = v5 concat v4 //does not work (implementation is not possible)
val v5_add = v5 + v5 //works
val fail2 = v5 + FixedSizeVector(4) //fails at compile-time
Compile-time protection for addition.
concat
does not work at all and is impossible to implement with just compiler externals.
Does not accept run-time values.
Length check is still done at run-time.
length
as a compile-time literal type
class FixedSizeVector[L <: Int with Singleton] private (implicit l : ValueOf[L]) {
val length : Int = valueOf[L]
def concat[R <: Int with Singleton](that : FixedSizeVector[R]) = FixedSizeVector.create[???] //IMPOSSIBLE
def +(that : FixedSizeVector[L]) = FixedSizeVector.create[L]
}
object FixedSizeVector {
protected def create[L <: Int with Singleton](implicit l : ValueOf[L]) = new FixedSizeVector[L]
def apply[L <: Int with Singleton](implicit l : ValueOf[L]) = {
require(valueOf[L] > 0, "length must be positive")
create[L]
}
}
val v5 = FixedSizeVector[5] //works
var four = 4
val v4 = FixedSizeVector[four] //not possible, of course
val v0_fail = FixedSizeVector[0] //fails at run-time
val v9 = v5 concat v4 //does not work (implementation is not possible)
val v5_add = v5 + v5 //works
val v9_fail = v5 + FixedSizeVector[4] //fails at compile-time
Compile-time protection for addition.
concat
does not work at all and is impossible to implement with just compiler externals.
Does not accept run-time values.
Length check is still done at run-time.
Awkward calling with length in just type position.
length
as a compile-time literal singleton-ops operation - Method 1
import singleton.ops._
type CheckPositive[I] = RequireMsg[I > 0, "length must be positive"]
class FixedSizeVector[L <: Int with Singleton] private (val length : Int) {
def concat[R <: Int with Singleton](that : FixedSizeVector[R])(implicit l : SafeInt[L + R]) = FixedSizeVector.create[l.Out](l.value)
def + (that : FixedSizeVector[L]) = FixedSizeVector.create[L](length)
}
object FixedSizeVector {
protected def create[L <: Int with Singleton](length : Int) = new FixedSizeVector[L](length)
implicit def apply[L <: Int with Singleton](implicit check : CheckPositive[L], length : SafeInt[L]) = create[L](length)
def apply[L <: Int with Singleton](length : L)(implicit check : CheckPositive[L]) = create[L](length)
}
val v5 = FixedSizeVector[5] //works
var four = 4
val v4 = FixedSizeVector(four) //not possible, compile-time only
val v0_fail = FixedSizeVector[0] //fails at compile-time
val v9 = v5 concat FixedSizeVector(4) //works
val v5_add = v5 + v5 //works
val v9_fail = v5 + FixedSizeVector[4] //fails at compile-time
val v3 = implicitly[FixedSizeVector[3]] //works
val v6 = FixedSizeVector(3+3) //works
val v6_fail = FixedSizeVector[3+3] //fails. Type operation is not an `Int with Singleton`
Complete compile-time protection and working functionality.
Dual use of length in both type position and value position.
Can be constructed implicitly.
Does not accept run-time values.
Does not accept type operation in length type position.
length
as a compile-time literal singleton-ops operation - Method 2
import singleton.ops._
type CheckPositive[I] = RequireMsg[I > 0, "length must be positive"]
class FixedSizeVector[L] private (val length : Int) {
def concat[R](that : FixedSizeVector[R]) = FixedSizeVector.create[L+R](this.length + that.length)
def + (that : FixedSizeVector[L]) = FixedSizeVector.create[L](length)
}
object FixedSizeVector {
protected def create[L](length : Int) = new FixedSizeVector[L](length)
implicit def apply[L](implicit check : CheckPositive[L], length : SafeInt[L]) = create[L](length)
def apply[L <: Int with Singleton](length : L)(implicit check : CheckPositive[L]) = create[L](length)
}
val v5 = FixedSizeVector[5] //works
var four = 4
val v4 = FixedSizeVector(four) //not possible, compile-time only
val v0_fail = FixedSizeVector[0] //fails at compile-time
val v9 = v5 concat FixedSizeVector(4) //works, but returns FixedSizeVector[5+4] and not FixedSizeVector[9]
val v5_add = v5 + v5 //works
val v9_fail = v5 + FixedSizeVector[4] //fails at compile-time
val v3 = implicitly[FixedSizeVector[3]] //works
val v6 = FixedSizeVector(3+3) //works
val v7 : FixedSizeVector[7] = FixedSizeVector[3+4] //works (implicit conversion)
Complete compile-time protection and working functionality.
Dual use of length in both type position and value position.
Can be constructed implicitly.
Accepts type operation in length type position.
Less implicits.
concat
returns a type operation in length and not a number (see comment of v9). However, implicit conversion produces evidence of equivalence in literal value (e.g., val a : 5+4 = 9; val b : 3+3 = implicitly[2*3]
)
Does not accept run-time values.
length
as a compile-time literal refined value - To be completed
Maybe I’ll add this later (feel free to comment with your own solution)
length
as a compile-time literal Nat
value - To be completed
Maybe I’ll add this later (feel free to comment with your own solution)
Even bigger problems
Lets assume we are willing to go ahead and create a FixedSizeVector.Safe
and FixedSizeVector.Unsafe
for compile&run time usage, respectively. What happens if we want to interact between safe and unsafe vectors (e.g, concatenating safe and unsafe vectors should result in an unsafe vector)? We end up duplication functionality in various combination of safety. Horrible!
TwoFace
and Checked
values to the rescue…
In a recent branch of singleton-ops, two value classes were added under a twoface
sub-package. The package adds TwoFace.XXX[T]
and Checked.XXX[T, Cond, Param, Msg]
, where XXX
can be Char
, Int
, Long
, Float
, Double
, String
, and Boolean
.
TwoFace
TwoFace
holds both run-time values and type representation.
import singleton.ops._
import singleton.twoface._
val tf2_a = TwoFace.Int(2) //returns TwoFace.Int[2] {val value = 2}
val tf2_b = TwoFace.Int[2] //returns TwoFace.Int[2] {val value = 2}
var two = 2
val tf2_c = TwoFace.Int(two) //returns TwoFace.Int[Int] {val value = 2}
tf2_a + tf2_b //returns TwoFace.Int[4] {val value = 4}
tf2_a + tf2_c //returns TwoFace.Int[4+Int] {val value = 4}. The type remains as its non-literal representation.
The type T
holds the literal type or a fall-back indication if non-literal representation is available.
Checked
Checked
is a constrained TwoFace
.
Example
Let’s take a look at our FixedSizeVector
now.
import singleton.ops._
import singleton.twoface._
class FixedSizeVector[L] private (val length : TwoFace.Int[L]) {
def concat[R](that : FixedSizeVector[R]) = FixedSizeVector.create(this.length + that.length)
def +(that : FixedSizeVector[L]) = FixedSizeVector.create(this.length)
}
object FixedSizeVector {
//For compile-time check
protected type CondCheckedLength[L, P] = L > 0
protected type ParamCheckedLength = 0
protected type MsgCheckedLength[L, P] = "Length must be positive (received value of " + ToString[L] + ")"
type CheckedLength[L] = Checked.Int[L, CondCheckedLength, ParamCheckedLength, MsgCheckedLength]
//For run-time check
implicit object RuntimeCheckedLength extends Checked.Runtime[Int, Int, CondCheckedLength, MsgCheckedLength] {
def cond(l : Int, p : Option[Int]) : scala.Boolean = l > 0
def msg(l : Int, p : Option[Int]) : java.lang.String = s"Length must be positive (received value of $l)"
}
protected def create[L](length : TwoFace.Int[L]) : FixedSizeVector[L] = new FixedSizeVector[L](length)
def apply[L](checkedLength : CheckedLength[L]) = create(checkedLength.unsafeCheck())
implicit def apply[L](implicit checkedLength : CheckedLength[L], di : DummyImplicit) = create(checkedLength.unsafeCheck())
}
Supports both compile-time and run-time constrained functionality.
If compile-time check passed, run-time check is not performed (should be dropped from the byte code).
Compile-time calculations can save run-time calculations (still unknown if the compiler identified the compiled execution as final and removed the run-time byte code).
Dual use of length in both type position and value position.
Can be constructed implicitly (the safe values).
Accepts type operation in length type position.
No more implicits, just use TwoFace
and Checked
Equivalent error message between run-time and compile-time, supporting placing values inside the error message for better reporting.
concat
still returns a type operation in length and not a number, but this is insignificant.
Discussion
Basically, I wish to hear your thoughts on this. Is this truly the right way to do this? Can’t the compiler provide us the means to achieve this with less mechanics?