Details ~ Scope Nesting - miniboxing/ildl-plugin GitHub Wiki

If you haven't read the [[introduction|Tutorial--Introduction]] and [[first example|Tutorial--Example-(Part-1)]] already, it's a good time to do so, as this advanced discussion assumes familiarity with the ildl-plugin.

This page presents the interactions between different adrt transformation scopes. Most of these interactions are exactly what programmers expect, but there are a few corner cases might be surprising. The invariant among these interactions is that they preserve correctness, which would otherwise be difficult to guarantee using manual transformations or implicit conversions.

The scoping rules presented are part of the [[ildl-plugin test suite|Tutorial-~-Running-the-Test-Suite]] and are thus checked with each build of the compiler plugin. For each of the scoping cases, we will point at the corresponding test file, along with the compiler flags that are passed and the expected output.

If you have either the demo vm or a local installation, you can use the ildl-scala and ildl-scalac scripts to run the examples on your own. For example, given this test file with its flags file, we can run:

$ cd ildl-plugin
$ ildl-scalac tests/correctness/resources/tests/scopes-nested.scala -Xprint:ildl-commit
tests/correctness/resources/tests/scopes-nested.scala:23: warning: The new 
operator can be optimized if you define a public, non-overloaded and matching
constructor method for it in object IntPairAsLong, with the name ctor_Tuple2:
      var n1 = (1, 0)
               ^
[[syntax trees at end of               ildl-commit]] // scopes-nested.scala
...

The output should match the one in the check file.

The transformation description objects used to explain the scoping rules are stubs and would not be used as such in real transformations. However, they serve the purpose of demonstrating how the ildl-plugin behaves in different cases. With this long list of disclaimers, let's see those scopes!

Overlapping Scopes

A limitation one might think of when seeing the lexically-scoped adrt transformations is that it is impossible to have overlapping scopes without one completely including the other. For example:

 + This code should be transformed using IntPairToLong
 | 
 |     val pair = (1, 0)
 |
 | + This code should be transformed using StringToArrayOfChar
 | |
 | |   val text = pair.toString
   |   text.substring(1,1)
   |   ...
   |

A programmer might want some part of the program (e.g. the first and second statements) to be transformed by a first scope (IntPairToLong) and an overlapping part (e.g. the second and third statements) by a second scope (StringToArrayOfChar).

It is indeed impossible to have overlap without nesting for lexical scopes. However, the lexical scopes can be split in three regions, each with its own set of transformations:

adrt(IntPairToLong) {
  val pair = (1, 0)
} 
adrt(IntPairToLong) {
  adrt(StringToArrayOfChar) {
    val text = pair.toString
  }
}
adrt(StringToArrayOfChar) {
  text.substring(1,1)
  ...
}

This way, the two overlapping scopes have been transformed into 3 disjoint scopes, two with a single transformation and one with two (nested) transformations. Which takes us to the next point, disjoint scopes.

Disjoint Scopes

Although disjoint, several scopes may still interact through the values that are being passed from one scope to the next. We will look at two examples.

Disjoint Scopes - Same High type

Values from one scope can be aliased or assigned to values in other scopes, as long as the High type (= semantic meaning) of the two values is the same:

adrt(IntPairAsLong)  {  val n1 = (1, 0)  }
adrt(IntPairAsFloat) {  val n2 = n1      }

The IntPairAsLong and IntPairAsFloat transformation description objects are shown below:

object IntPairAsLong extends RigidTransformationDescription {
  type High = (Int, Int)
  type Repr = Long
  def toRepr(pair: (Int, Int)): Long @high = ...
  def toHigh(l: Long @high): (Int, Int) = ...
}

object IntPairAsFloat extends RigidTransformationDescription {
  type High = (Int, Int)
  type Repr = Float
  def toRepr(pair: (Int, Int)): Float @high = ...
  def toHigh(l: Float @high): (Int, Int) = ...
}

Before the adrt transformation, both values are of high-level type (Int, Int). However, after the transformation, inside the first scope, (Int, Int) is transformed to Long, while in the second it is transformed to Float.

Now, how can the assignment in the second line of code be implemented? Would it be okay to just assign n1, of type Long, to n2, of type Float? Despite the fact that Long values can be assigned to Float, assigning n1 to n2 would be incorrect, as it would lose the high-level program semantic, where both n1 and n2 are encoded pairs of integers. Let us see how the example is transformed by visiting each ildl-plugin transformation phase.

During the inject phase the adrt scope markers are removed and are substituted by the injected @repr annotations, parameterized by the transformation object:

val n1: (Int, Int) @repr(IntPairAsLong) = scala.Tuple2.apply[Int, Int](1, 0);
val n2: (Int, Int) @repr(IntPairAsFloat) = n1;

So far, the assignment is still correct, as both values have the (Int, Int) high-level type. The [[ildl-bridge phase|Details-~-Bridges]] leaves the tree intact, as there are no overrides.

In the coercion phase, the ildl-plugin allows the representation types to take over. Thus, while n1 still has type (Int, Int) @repr(IntPairAsLong), it is no longer compatible with either (Int, Int) or (Int, Int) @repr(IntPairAsFloat). In order to resolve this incompatibility, the toRepr and toHigh methods of the transformation description objects are invoked:

val n1: (Int, Int) @repr(IntPairAsLong) = IntPairAsLong.toRepr(new (Int, Int)(1, 0));
val n2: (Int, Int) @repr(IntPairAsFloat) = IntPairAsFloat.toRepr(IntPairAsLong.toHigh(n1()));

The reason for introducing so many toRepr and toHigh calls in the ildl-coerce phase becomes apparent in the ildl-commit, where the actual representation types are used:

val n1: Long = IntPairAsLong.toRepr(new (Int, Int)(1, 0));
val n2: Float = IntPairAsFloat.toRepr(IntPairAsLong.toHigh(n1()));

As mentioned above, correctness is a primary concern in the ildl transformation. Therefore, the n1 value, meant to be represented as a long integer, could not be directly assigned to a floating-point number, as that would lose its semantic (Int, Int) meaning. Instead, the ildl-coerce phase transformed n1 from Long to (Int, Int) first and then from (Int, Int) to Float using the toRepr and toHigh methods provided by the transformation description objects.

This example is checked automatically in the test suite: test file, flags file and check file.

Disjoint Scopes - Using Supertypes

A different case occurs when a transformed value escapes to a different scope, but it is used as a supertype of the High type:

adrt(IntPairAsLong)  {  
  val n1 = (1, 0)  
}
  val n2: Any = n1

In this case, if we were to use implicit conversions from (Int, Int) to Long and back, it would be insufficient, since n1 is converted to Long, which is a subtype of Any, therefore the compiler would allow the assignment to go through without an implicit conversion.

The adrt scope, on the other hand, marks the n1 value with the @repr annotation, making it incompatible with any non-annotated type. Thus: (Int, Int) @repr(IntPairAsLong) is not a subtype of Any, Scala's top of the type system. Instead, a coercion is introduced by the ildl-coerce phase:

  val n1: (Int, Int) @repr(IntPairAsLong) = ...
  val n2: Any = IntPairAsLong.toHigh(n1)

Finally, the ildl-commit phase transforms the code to:

  val n1: Long = ...
  val n2: Any = IntPairAsLong.toHigh(n1)

Again, the focus on correctness is apparent: even a diligent programmer who would prepare the implicit conversions and import them into the scope would still have trouble with this example, as there would not be any compiler error -- the code would be compiled successfully and a Long value would pop up instead of the expected pair of integers (Int, Int).

This example is checked automatically in the test suite: test file, flags file and check file.

Disjoint Scopes - Collaborating

Let us now assume we have defined the BigIntAsLong scope:

  object BigIntAsLong extends TransformationDescription {
    def toRepr(high: BigInt): Long @high = {
      assert(high.isValidLong)
      high.longValue()
    }
    def toHigh(repr: Long @high): BigInt = BigInt(repr)
    ...
  }

So far, so good. But the program you are using also uses a Queue[BigInt] to store values, like the Hamming numbers example. Unfortunately, the ildl transformation is not capable of transforming the BigIng high-level type inside generics, since it's not in control of what the Queue collection does to the values.

To optimize the Queue[BigInt] objects, another transformation description object is necessary:

  object BigIntQueueAsLongQueue extends TransformationDescription {
    def toRepr(high: Queue[BigInt]): Queue[Long] @high =
      high.map(x => { assert(x.isValidLong); x.longValue() })
    def toHigh(repr: Queue[Long] @high): Queue[BigInt] =
      repr.map(x => BigInt(x))
    ...
  }

A question that arises is how are the enqueue/dequeue operations transformed? Since the BigIntQueueAsLongQueue only handles Queue[BigIng], it does not transform the BigInt accepted by enqueue and returned by the dequeue operation to a Long. That's a shame -- whenever we pass values to the queue, the program needs to convert them to BigInt, losing performance. The following code would be transformed to:

  adrt(BigIntAsLong) {
    adrt(BigIntQueueAsLongQueue) {
      val q: Queue[BigInt] = Queue[BigInt](BigInt(1), BigInt(2), BigInt(3))
      val v: BigInt = q.dequeue
    } 
  }

Is transformed to:

  val q: Queue[Long] = ...
  val v: Long = BigIntAsLong.toRepr(q.dequeue) // too bad!

Fortunately, we can fix this by using the optional argument of the @high annotation:

  object BigIntQueueAsLongQueue extends TransformationDescription {
    ... // toHigh, toRepr
    def extension_dequeue(queue: Queue[Long] @high): Long @high(BigIntAsLong) =
      queue(idx)
  }

What the @high(BigIntAsLong) says: the Long is encoded with the BigIntAsLong transformation and corresponds to the BigInt result. Oh goodie! The result is:

  val q: Queue[Long] = ...
  val v: Long = q.dequeue

This example is checked automatically in the test suite: test file, flags file and check file.

The next section will show nested scopes.

Nested Scopes

Nested scopes allow several transformations to be composed, opening the door to more comprehensive program changes. However, with power comes responsability, and there are several cases where users need to be careful. Still, even in those corner cases, the code generated is correct and the ildl-plugin warns the programmer and explains the situation.

We will look at a correct example and then move on to corner cases.

Nested Scopes - Composing Transformations

In the case of nested adrt scopes, the ildl-plugin composes the transformations, something otherwise impossible to define in a single transformation description object. For example, the following method, foo, has two parameters, both of which could be represented differently:

adrt(IntPairAsLong) {
  adrt(IntAsLong) {
    def foo(n1: (Int, Int), n2: Int): Int = ???
  }
}

This transformation could not be specified as a single transformation description object, since each transformation description object can only target a single High type. However, composition, achieved through nested scopes, can combine several transformations into one. Following the ildl-inject phase, the two parameters of method foo are marked for transformation independently, using their respective transformation description objects:

def foo(n1: (Int, Int) @repr(IntPairAsLong), n2: Int @repr(IntAsLong)): Int @repr(IntAsLong) = ...

Jumping directly to the ildl-commit phase, the signature of method foo is going to be transformed to:

def foo(n1: Long, n2: Long): Long = ...

This example is checked automatically in the test suite: test file, flags file and check file.

Nested Scopes - Targeting the Same High Type

One of the limitations of nested scopes is that each transformation description object should target a different High type. Should this not be the case, the ildl-plugin will report the issue:

adrt(IntPairAsLong) {
  adrt(IntPairAsFloat) {
    // `n1` can be transformed by two scopes:
    //  * `IntPairAsFloat` - to a floating point number
    //  * `IntPairAsLong` - to a long integer
    // => the innermost description object wins: IntPairAsFloat
    val n1 = (1, 0)
  }
  val n2 = (2, 3)
}

The warning the programmer will see is:

$ ildl-scalac scopes-nested-same-high.scala
scopes-nested-same-high.scala:24: warning: Several `adrt` scopes can be applied here. The innermost will apply: GCDTest.this.IntPairAsFloat
val n1 = (1, 0)
         ^

To see the result of the transformation, we omit the ildl-inject, ildl-bridge and ildl-coerce phases and jump directly to the ildl-commit phase:

  val n1: Float = IntPairAsFloat.toRepr(new (Int, Int)(1, 0));
  val n2: Long = IntPairAsLong.toRepr(new (Int, Int)(2, 3));

Notice that the innermost scope, IntPairAsFloat, won, imposing its representation type for n1. The outermost scope, IntPairAsLong, was able to transform the second statement, since there was no conflict.

This example is checked automatically in the test suite: test file, flags file and check file.

Nested Scopes - Targeting the Same Repr Type

A big pitfall of composing scopes is mis-interpreting a value as an incorrect High type. This can occur when multiple High types share the same Repr type, possibly allowing values to leak between them in the encoded representation type:

adrt(IntPairAsLong) {
  adrt(IntAsLong) {
    var n1 = (1, 0)
    var n2 = 1
    n2 = n1
    println(n1) // (1, 0)
    println(n2) // <garbage>
  }
}

Since both n1 and n2 will be represented as Long, there is a risk that the encoded pair of integers may be interpreted as an integer after the assignment in the 3rd line, creating a breach in the language semantics. Fortunately, this is not the case:

$ ildl-scalac scopes-conflicting-repr.scala
scopes-conflicting-repr.scala:27: error: type mismatch;
 found   : (Int, Int)
 required: Int
      n2 = n1
           ^

Since the program is type-checked at the high level, before the ildl transformation occurs, it is impossible to leak values between different High types through their common representation type. Yeey for correctness!

This example is checked automatically in the test suite: test file, flags file and check file.

Nested Scopes - Passing between High-level types

Sometimes programmers may need to communicate between different high-level types, such as, for example, between (Int, Int) and (Float, Float). This is possible in two ways:

  • Communicating through the high-level types, converting both tuple components to floating-point numbers or
  • Communicating through the representation types, in a commonly accepted format

The scopes-pickling.scala test shows the second point:

adrt(IntPairAsLong) {
  adrt(FloatPairAsLong) {
    val n1 = (1, 0)
    val n2 = (3f, 4f)
    val n3 = n2.unpickle(n1.pickle)
    val n4 = n1.unpickle(n2.pickle)
  }
}

Instead of creating a pair of integers and converting its components to floating-point values, the pickle and unpickle methods expect a long integer, which they treat as the representation type. However, since the encodings for (Int, Int) and (Float, Float) are different, the methods do require some work.

Still, what is interesting to notice is that no tuple object is created when pickling and unpickling:

$ ildl-scalac scopes-pickling.scala -Xprint:ildl-commit
[[syntax trees at end of               ildl-commit]] // scopes-pickling.scala
package test {
  object ScopePicklingTest extends Object {
    ...
    def main(args: Array[String]): Unit = {
      val n1: Long = IntPairAsLong.toRepr(new (Int, Int)(1, 0));
      val n2: Long = FloatPairAsLong.toRepr(new (Float, Float)(3.0, 4.0));
      val n3: Long = FloatPairAsLong.implicit_FloatPairPickle_unpickle(n2, IntPairAsLong.implicit_IntPairPickle_pickle(n1));
      val n4: Long = IntPairAsLong.implicit_IntPairPickle_unpickle(n1, FloatPairAsLong.implicit_FloatPairPickle_pickle(n2));
      println("".+(IntPairAsLong.toHigh(n1)).+(" and ").+(FloatPairAsLong.toHigh(n3)));
      println("".+(FloatPairAsLong.toHigh(n2)).+(" and ").+(IntPairAsLong.toHigh(n4)))
    }
  }
}

When running the example, the output is:

$ ildl-scala test.ScopePicklingTest
(1,0) and (1.0,0.0)
(3.0,4.0) and (3,4)

This example is checked automatically in the test suite: test file, flags file and check file.

Nested Scopes - Cascading Scopes

The last example is probably the most complex. It shows two nested transformations, from pairs of integers to long integers and then from long integers to floating-point numbers:

adrt(IntPairAsLong) {
  adrt(LongAsFloat) {
    val n1 = (1, 0)
  }
}

In this case, a valid question is what will the representation of n1 be? From one perspective, it is a pair of integers, thus it can be represented as a long integer. However, the inner nested scope, LongAsFloat, can transform the long integer into a floating-point number, transitively leading to the integer pair being represented as a floating point number: (Int, Int) -> Long -> Float.

The ildl-plugin performs a single Late Data Layout transformation step, so only the first step of the transformation is performed. Currently, the ildl-plugin will not warn about this problem, since computing the transitive step in general is extremely expensive, especially when [[using FreestyleTransformationDescription objects|Details-~-Transformation-Description]].

This example is checked automatically in the test suite: test file, flags file and check file.

Congratulations! After reading this in-depth tutorial, you are an expert in adrt scopes!

From here:

⚠️ **GitHub.com Fallback** ⚠️