scalascala-macrosscala-3

Why does quoted expression raise errors while equivalent AST does not?


In the following example, using a quoted expression ('{ ... }) raises a compiler error, while the equivalent AST construction works fine. Why does this happen?

Code Example

// Using Scala 3.7.0
trait Criteria[S] {
    transparent inline def discriminator[P <: S]: Int
}

transparent inline def discriminatorOf[S, P <: S]: Int = ${discriminatorOfImpl[S, P]}

private def discriminatorOfImpl[S: Type, P <: S: Type](using quotes: Quotes): Expr[Int] = {
    import quotes.reflect.*

    Expr.summon[Criteria[S]] match {
        case None =>
            report.errorAndAbort(s"No given of ${Type.show[Criteria[S]]} found")
        case Some(criteriaExpr) =>
            // Quoted version: Uncommenting it (the next line) would cause the compiler to raise the error: Deferred inline method discriminator in trait Criteria cannot be invoked
            // '{ $criteriaExpr.discriminator[P] }

            // AST version: supposedly equivalent to the quote in the previous line
            Select.unique(criteriaExpr.asTerm, "discriminator").appliedToType(TypeRepr.of[P]).asExprOf[Int]
    }
}

The error I get with the quoted version is: "Deferred inline method discriminator in trait Criteria cannot be invoked"

Questions:

  1. Why does the quoted version fail when the AST version works?
  2. Is my AST construction actually equivalent to the quoted version?
  3. How can I inspect what AST a quoted expression would generate when it does not compile?
  4. Is there a way to make the quoted version work in this case?

Test case

sealed trait Animal
case class Dog(dogField: Int) extends Animal
case class Cat(catField: String) extends Animal

given Criteria[Animal]:
    override transparent inline def discriminator[P <: Animal]: Int = {
        inline erasedValue[P] match {
            case _: Dog => 0
            case _: Cat => 1
        }
    }

@main def runSummon(): Unit = {
    val catDiscriminator: 1 = discriminatorOf[Animal, Cat]
    println(catDiscriminator)
}

The above test case also compiles (with the AST version) and runs as expected. Note that catDiscriminator is a constant, which tells that the Select.unique(criteriaExpr.asTerm, "discriminator").appliedToType(TypeRepr.of[P]).asExprOf[Int] expression was reduced to a constant. Therefore, the expression can and is evaluated eagerly during the macro expansion. Then why does the quote checker consider the call to the discriminator inline method as deferred?


Solution

  • There are different times:

    1. compile time of a macro,
    2. compile time of main code i.e. runtime of the macro i.e. the time of macro expansion,
    3. runtime of main code.

    Scala 2 reify {...}/.splice expressions-trees, Scala 3 quotations '{...}, '[...]/${...} are typechecked at the time 1 (and then at the time 2 once again, upon expansion).

    Scala 2 quasiquotes q"...", tq"..."/$..., ..$..., ...$..., c.parse-parsed trees, manually constructed trees, Scala 3 manually constructed trees are typechecked at the time 2.

    This makes a difference for

    1. Why does the quoted version fail when the AST version works?

    When you write the tree '{ $criteriaExpr.discriminator[P] } it should be typechecked at the time 1 (static type). When you write .asExprOf[Int] this should be checked at the time 2 (dynamic type). What static type should criteriaExpr have in '{ $criteriaExpr.discriminator[P] }? It can't have no type. There's nothing different from Criteria[S] and you have Deferred inline method cannot be invoked because of that: How to call an inline method from within a scala 3.6.4 macro? Probably compiler could postpone inferring type (? <: Criteria[S]) for this specific node of the whole program AST, keeping in mind that the type of implicit can be finer than Criteria[S] but apparently compiler doesn't. So we opened a ticket https://github.com/scala/scala3/issues/23134

    1. How can I inspect what AST a quoted expression would generate when it does not compile?

    In Scala 2 there was setting scalacOptions += "-Ymacro-debug-lite" (or "-Ymacro-debug-verbose"). In Scala 3 it's absent, so you should print specific tree manually:

    val expr = '{ $criteriaExpr.discriminator[P] }
    println(expr.asTerm.show(using Printer.TreeStructure))
    expr
    
    val expr = Select.unique(criteriaExpr.asTerm, "discriminator").appliedToType(TypeRepr.of[P]).asExprOf[Int]
    println(expr.asTerm.show(using Printer.TreeStructure))
    expr
    

    Also you can inspect the whole dump after corresponding compilation stage with

    scalacOptions ++= Seq("-Xprint-types", "-Vprint:typer")
    scalacOptions ++= Seq("-Xprint-types", "-Vprint:posttyper")
    scalacOptions ++= Seq("-Xprint-types", "-Vprint:inlining")
    scalacOptions ++= Seq("-Xprint-types", "-Vprint:postInlining")
    scalacOptions ++= Seq("-Xprint-types", "-Vprint:inlineVals")
    

    You can see the whole list of compilation stages for a current version of compiler with scalacOptions += "-Xshow-phases".

    https://dotty.epfl.ch/docs/contributing/architecture/phases.html

                     parser  scan and parse sources
                      typer  type the trees
       checkUnusedPostTyper  check for unused elements
             checkShadowing  check for elements shadowing other elements in scope
           inlinedPositions  check inlined positions
                   sbt-deps  sends information on classes' dependencies to sbt
    extractSemanticDBExtractSemanticInfo
                             extract info into .semanticdb files
                  posttyper  additional checks and cleanups after type checking
                 unrollDefs  generates forwarders for methods annotated with @unroll
              prepjsinterop  additional checks and transformations for Scala.js
                SetRootTree  set the rootTreeOrProvider on class symbols
                    pickler  generates TASTy info
                    sbt-api  sends a representation of the API of classes to sbt
                   inlining  inline and execute macros
               postInlining  add mirror support for inlined code
                    staging  check staging levels and heal staged types
                   splicing  splicing
               pickleQuotes  turn quoted trees into explicit run-time data
                             structures
    checkUnusedPostInlining  check for unused elements
         instrumentCoverage  instrument code for coverage checking
         crossVersionChecks  check issues related to deprecated and experimental
             firstTransform  some transformations to put trees into a canonical form
             checkReentrant  check no data races involving global vars
        elimPackagePrefixes  eliminate references to package prefixes in Select
                             nodes
               cookComments  cook the comments: expand variables, doc, etc.
      checkLoopingImplicits  check that implicit defs do not call themselves in an
                             infinite loop
                 betaReduce  reduce closure applications
                 inlineVals  check right hand-sides of an `inline val`s
                 expandSAMs  expand SAM closures to anonymous classes
               elimRepeated  rewrite vararg parameters and arguments
                  refchecks  checks related to abstract members and overriding
                 dropForMap  Drop unused trailing map calls in for comprehensions
    extractSemanticDBAppendDiagnostics
                             extract info into .semanticdb files
                initChecker  check initialization of objects
         protectedAccessors  add accessors for protected members
                 extmethods  expand methods of value classes with extension methods
        uncacheGivenAliases  avoid caching RHS of simple parameterless given aliases
                checkStatic  check restrictions that apply to @static members
                 elimByName  map by-name parameters to functions
             hoistSuperArgs  hoist complex arguments of supercalls to enclosing
                             scope
           forwardDepChecks  ensure no forward references to local vals
     specializeApplyMethods  adds specialized methods to FunctionN
           tryCatchPatterns  compile cases in try/catch
             patternMatcher  compile pattern matches
                 preRecheck  preRecheck
                    recheck  recheck
                    setupCC  prepare compilation unit for capture checking
                         cc  capture checking
                 elimOpaque  turn opaque into normal aliases
          explicitJSClasses  make all JS classes explicit
              explicitOuter  add accessors to outer classes from nested ones
               explicitSelf  make references to non-trivial self types explicit as
                             casts
              interpolators  optimize s, f, and raw string interpolators
                 dropBreaks  replace local Break throws by labeled returns
            pruneErasedDefs  drop erased definitions and simplify erased expressions
              uninitialized  eliminates `compiletime.uninitialized`
             inlinePatterns  remove placeholders of inlined patterns
            vcInlineMethods  inlines calls to value class methods
                seqLiterals  express vararg arguments as arrays
                intercepted  rewrite universal `!=`, `##` methods
                    getters  replace non-private vals and vars with getter defs
        specializeFunctions  specialize Function{0,1,2} by replacing super with
                             specialized super
           specializeTuples  replaces tuple construction and selection trees
      collectNullableFields  collect fields that can be nulled out after use in lazy
                             initialization
            elimOuterSelect  expand outer selections
               resolveSuper  implement super accessors
      functionXXLForwarders  add forwarders for FunctionXXL apply methods
            paramForwarding  add forwarders for aliases of superclass parameters
              genericTuples  optimize generic operations on tuples
               letOverApply  lift blocks from receivers of applications
          arrayConstructors  intercept creation of (non-generic) arrays and
                             intrinsify
                    erasure  rewrite types to JVM model
        elimErasedValueType  expand erased value types to their underlying
                             implementation types
                  pureStats  remove pure statements in blocks
         vcElideAllocations  peep-hole optimization to eliminate unnecessary value
                             class allocations
                  etaReduce  reduce eta expansions of pure paths
                 arrayApply  optimize `scala.Array.apply`
         addLocalJSFakeNews  adds fake new invocations to local JS classes in calls
                             to `createLocalJSClass`
           elimPolyFunction  rewrite PolyFunction subclasses to FunctionN subclasses
                    tailrec  rewrite tail recursion to loops
          completeJavaEnums  fill in constructors for Java enums
                      mixin  expand trait fields and trait initializers
                   lazyVals  expand lazy vals
                    memoize  add private fields to getters and setters
            nonLocalReturns  expand non-local returns
               capturedVars  represent vars captured by closures as heap objects
               constructors  collect initialization code in primary constructors
            instrumentation  count calls and allocations under -Yinstrument
                 lambdaLift  lifts out nested functions to class scope
             elimStaticThis  replace This references to static objects by global
                             identifiers
         countOuterAccesses  identify outer accessors that can be dropped
         dropOuterAccessors  drop unused outer accessors
      dropParentRefinements  drop parent refinements from a template
           checkNoSuperThis  check that supercalls don't contain references to This
                    flatten  lift all inner classes to package scope
         transformWildcards  replace wildcards with default values
                 moveStatic  move static methods from companion to the class itself
              expandPrivate  widen private definitions accessed from nested classes
              restoreScopes  repair rendered invalid scopes
               selectStatic  get rid of selects that would be compiled into
                             GetStatic
         junitBootstrappers  generate JUnit-specific bootstrapper classes for
                             Scala.js
       Collect entry points  collect all entry points and save them in the context
          collectSuperCalls  find classes that are called with super
      repeatableAnnotations  aggregate repeatable annotations
                   genSJSIR  generate .sjsir files for Scala.js
                   genBCode  generate JVM bytecode
    
    1. Is my AST construction actually equivalent to the quoted version?

    Obviously they are not equivalent since the the former compiles while the latter doesn't :) Or you should better define "equivalence".

    If we temporarily comment out transparent inline

    trait Criteria[S] {
      /*transparent inline*/ def discriminator[P <: S]: Int
    }
    
    given Criteria[Animal]:
      override /*transparent inline*/ def discriminator[P <: Animal]: Int = {
        ???
    //  inline erasedValue[P] match {
    //    case _: Dog => 0
    //    case _: Cat => 1
    //  }
      }
    

    then with .show(using Printer.TreeStructure) we can see

    // quotation '{...}
    Inlined(Some(TypeIdent("Macros$")), Nil, TypeApply(Select(Inlined(None, Nil, Ident("given_Criteria_Animal")), "discriminator"), List(Inferred())))
    
    // manual AST
    TypeApply(Select(Ident("given_Criteria_Animal"), "discriminator"), List(Inferred()))
    

    that the trees are similar (although not identical because of Inlined wrapping trees). So the difference in behavior is mostly because of the typechecking at different times (1 or 2), not because of completely different tree structure.

    1. Is there a way to make the quoted version work in this case?

    I can't see such option. How should criteriaExpr be typed in '{ $criteriaExpr.discriminator[P] } is something that should be implemented in compiler. Statically it's Criteria[S], dynamically something finer. We would prefer this to be something finer than Criteria[S] statically. Alas.

    There are always programs that can't fail at runtime but are rejected by compiler. For example if true then 1 else "a" is dynamically Int but val x: Int = if true then 1 else "a" doesn't compile.