Skip to content

Fix Constant tag of Expr(null: String) #23064

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

Open
wants to merge 2 commits into
base: main
Choose a base branch
from

Conversation

mbovel
Copy link
Member

@mbovel mbovel commented Apr 28, 2025

Fixes #23008. Started during the Scala 3 Compiler Spree of Monday, April 28th, 2025.

The Constant is an object in Dotty core that has several apply overloads, including Constant(String) and Constant(Null), that create Constant instances with the appropriate tags:

def apply(x: Null): Constant = new Constant(x, NullTag)
def apply(x: String): Constant = new Constant(x, StringTag)

It is called from the quotes reflection API with an argument x: String, which always dispatched to the former overload:

def apply(x: String): StringConstant = dotc.core.Constants.Constant(x)

When this API is called from code without explicit nulls, null can be passed as argument x. That still dispatches to the Constant(String) overload, yielding a Constant with the wrong tag.

This PR fixes the issue by forbidding null as an argument of StringConstant, but allowing it as an argument of StringToExpr and returning a proper NullConstant (a constant with tag NullTag) in that case.

Co-authored-by: Hamza Remmal <hamza@remmal.net>
Co-authored-by: HarrisL2 <harris.yh.lau@gmail.com>
Co-authored-by: Abdullah Arif Jafri <abdullahjafri2001@gmail.com>
@@ -2529,7 +2529,7 @@ class QuotesImpl private (using val ctx: Context) extends Quotes, QuoteUnpickler
end StringConstantTypeTest

object StringConstant extends StringConstantModule:
def apply(x: String): StringConstant = dotc.core.Constants.Constant(x)
def apply(x: String): StringConstant = dotc.core.Constants.Constant(x: Any)
Copy link
Member

Choose a reason for hiding this comment

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

This is a fix to what I believe is a soundness hole in explicit nulls. I'm going to try to have another example with more details about this soundness issue.

Copy link
Member

Choose a reason for hiding this comment

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

If there is unsafe data feed into explicit nulls, there is nothing we can do other than runtime check.

Copy link
Contributor

Choose a reason for hiding this comment

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

If this is a valid fix, then comments is needed inline

Choose a reason for hiding this comment

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

May be it is better to add:

object dotc.core.Constants.Constant{
  val nullConstant: Constant  = new Constant(null, NullTag)
}

def apply(x: String): StringConstant = if (x==null) {dotc.core.Constants.nullConstant} else {dotc.core.Constants.Constant(x)}

Copy link
Member Author

@mbovel mbovel Apr 30, 2025

Choose a reason for hiding this comment

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

I was also surprised when I discovered true/false/null constants are not re-used/interned/hash-consed. I don't know if that would have a significant impact. I think this could be discussed in a separate issue or PR.

@mbovel mbovel requested a review from olhotak April 28, 2025 16:39
@olhotak
Copy link
Contributor

olhotak commented Apr 28, 2025

Is the intended behaviour of Expr(null: String) specified anywhere, such as in the Javadoc comments of Expr in the scala.quoted API, or anywhere else? I couldn't find it. Is one allowed to pass null here or not?

@HarrisL2
Copy link
Contributor

Is the intended behaviour of Expr(null: String) specified anywhere, such as in the Javadoc comments of Expr in the scala.quoted API, or anywhere else? I couldn't find it. Is one allowed to pass null here or not?

I believe its not allowed, but not very well documented.

assert(const.value != null, const) // TODO this invariant isn't documented in `case class Constant`

@Alexey-NM
Copy link

Alexey-NM commented Apr 29, 2025

I believe its not allowed

If this is the case, then Expr(null: String) should throw an exception. We spent a lot of time trying to understand why exprString.show was throwing an error. Actually we didn't explicitly write Expr(null: String) instead, we performed a conversion from a Java POJO. This was not immediately obvious, and we were quite frustrated to discover that the root cause was related to Expr, which is defined far away and comes with implicit imports.

If this is indeed how it works, it represents a rather unfriendly design when interacting with Java objects.

@Alexey-NM
Copy link

IMHO: The example of a good design is Option

  /** An Option factory which creates Some(x) if the argument is not null,
   *  and None if it is null.
   *
   *  @param  x the value
   *  @return   Some(value) if value != null, None if value == null
   */
  def apply[A](x: A): Option[A] = if (x == null) None else Some(x)

@mbovel
Copy link
Member Author

mbovel commented Apr 29, 2025

I believe its not allowed, but not very well documented.

The point here is that a Dotty Constant with tag StringTag cannot have the value null:

case StringTag =>
assert(const.value != null, const) // TODO this invariant isn't documented in `case class Constant`

However, Constant(null, NullTag) is valid. Therefore, I think the questions to accept null: Null or null: String as arguments to Expr, StringToExpr, and StringConstant in the quotes API should be treated independently of this assertion.

Is the intended behaviour of Expr(null: String) specified anywhere, such as in the Javadoc comments of Expr in the scala.quoted API, or anywhere else? I couldn't find it. Is one allowed to pass null here or not?

I also could not find any documentation.

Thus, it seems up to us to decide whether Expr(null: String) should throw an exception or instead produce a well-formed null constant (as this PR proposes).

Accepting null seems reasonable to me: I only see advantages so far (notably compatibility with code without explicit nulls). Are there any drawbacks?

@olhotak
Copy link
Contributor

olhotak commented Apr 29, 2025

If we decide to allow null, can we document this by making the signature of StringConstant.apply take a String|Null instead of String?

@mbovel
Copy link
Member Author

mbovel commented Apr 30, 2025

If we decide to allow null, can we document this by making the signature of StringConstant.apply take a String|Null instead of String?

Indeed, that would also look like nicer to us! We discussed it on Monday during the Spree and concluded that would be a backward-incompatible change, but I am actually not sure.

@mbovel
Copy link
Member Author

mbovel commented Apr 30, 2025

By the way, while trying to change the parameter type to String | Null, I noticed Constant(x: String | Null) in compiler/src/scala/quoted/runtime/impl/QuotesImpl.scala seems to resolve to Constant(String) and not to Constant(Any), as I would expect with explicit nulls enabled. Any idea why?

@noti0na1
Copy link
Member

By the way, while trying to change the parameter type to String | Null, I noticed Constant(x: String | Null) in compiler/src/scala/quoted/runtime/impl/QuotesImpl.scala seems to resolve to Constant(String) and not to Constant(Any), as I would expect with explicit nulls enabled. Any idea why?

I guess the file calling the quotes api is not in explicit nulls, so String | Null will still be a subtype of String.

@mbovel
Copy link
Member Author

mbovel commented Apr 30, 2025

Something else I just noticed: the method returns a StringConstant, which is defined as a Constant with tag StringTag:

object StringConstantTypeTest extends TypeTest[Constant, StringConstant]:
def unapply(x: Constant): Option[StringConstant & x.type] =
if x.tag == dotc.core.Constants.StringTag then Some(x.asInstanceOf[StringConstant & x.type]) else None
end StringConstantTypeTest

So we shouldn't return a Constant with tag NullTag under this return type.

We could change the return type to StringConstant | NullConstant, but that starts to feel convoluted.

Maybe it's better after all to just require a non-null argument?

@mbovel
Copy link
Member Author

mbovel commented Apr 30, 2025

I guess the file calling the quotes api is not in explicit nulls, so String | Null will still be a subtype of String.

But isn't the overload statically resolved, in the file with explicit nulls? In the situation I describe, the call Constant(x: String | Null) is in compiler/src/scala/quoted/runtime/impl/QuotesImpl.scala, not in a foreign file.

@noti0na1
Copy link
Member

noti0na1 commented Apr 30, 2025

Oh, I see what you meam. No, explicit nulls is disabled in QuotesImpl by importing unsafeNulls.

@mbovel
Copy link
Member Author

mbovel commented Apr 30, 2025

Oh, I see what you meam. No, explicit nulls is disabled in QuotesImpl by importing unsafeNulls.

Ah, that explains it! Thanks, I missed this import.

@noti0na1
Copy link
Member

Maybe it's better after all to just require a non-null argument?

I'm ok with this, given a null string is always not allowed, it's better to throw the exception ealier.

@Alexey-NM
Copy link

Alexey-NM commented Apr 30, 2025

Maybe it's better after all to just require a non-null argument?

I'm ok with this, given a null string is always not allowed, it's better to throw the exception ealier.

We can solve the issue in StringToExpr
So this is not the reason to throw the exception

@mbovel
Copy link
Member Author

mbovel commented Apr 30, 2025

We can solve the issue in StringToExpr
So this is not the reason to throw the exception

What do you suggest for StringToExpr? To handle null there?

In any case, we still need to do something for StringConstant itself as it is also part of the public API, don't we?

@Alexey-NM
Copy link

In any case, we still need to do something for StringConstant itself as it is also part of the public API, don't we?

IIUC: Our primary issue is that StringToExpr return broken expression without any exception:

 import quotes.reflect.*
 val str: String = null
 val exprString = Expr(str)

It seems we can fix it just to rewrite StringToExpr

  given StringToExpr[T <: String]: ToExpr[T] with {
    def apply(x: T)(using Quotes) =
      import quotes.reflect.*
      Literal(if (x==null) {nullListeral} else {StringConstant(x)}).asExpr.asInstanceOf[Expr[T]]
  }

it seems the issue can be resolved without modifying the declaration of StringConstant.

@Alexey-NM
Copy link

In any case, we still need to do something for StringConstant itself as it is also part of the public API, don't we?

IMHO: Yes, StringConstant must not return broken result silently.

@mbovel
Copy link
Member Author

mbovel commented Apr 30, 2025

I have just pushed a commit that allows null in StringToExpr, but not in StringConstant, following @noti0na1 and @Alexey-NM's comments.

@mbovel
Copy link
Member Author

mbovel commented Apr 30, 2025

But now if we allow null for StringToExpr, maybe we should allow it for all other ToExpr instances as well, which might be overkill 😕

@Alexey-NM
Copy link

Alexey-NM commented Apr 30, 2025

But now if we allow null for StringToExpr, maybe we should allow it for all other ToExpr instances as well, which might be overkill 😕

IMHO: There are no other java primitives in the ToExpr which can be null.
May be:

  • BigIntToExpr
  • BigDecimalToExpr

But it is scala types and may be it should not be null in any case.

@Alexey-NM
Copy link

Alexey-NM commented Apr 30, 2025

But now if we allow null for StringToExpr, maybe we should allow it for all other ToExpr instances as well, which might be overkill 😕

Throwing exception in dotc.core.Constants.Constant(x) for broken cases may be usefull.
It is actual for String and Type.

@mbovel
Copy link
Member Author

mbovel commented Apr 30, 2025

There are no other java primitives in the ToExpr which can be null.

Yes, but what about non-primitive types? We also get runtime errors in these cases if we pass null, although these are not related to Constant, for example:

  def nullTestImpl()(using Quotes): Expr[Unit] =
    Expr(null: Option[Int]) // MatchError: null
    Expr(null: (Int, Int)) // NullPointerException: Cannot invoke "scala.Tuple2._1()" because "tup$2" is null
    Expr(null: Array[Int]) // NullPointerException: Cannot read the array length because "array" is null

@Alexey-NM
Copy link

Yes, but what about non-primitive types? We also get runtime errors in these cases, for example:

IMHO:

  • Expr(null: Option[Int]) // MatchError: null
    Exception is correct because such writing should not occur.

  • Expr(null: Array[Int])
    Since It is a Java type, it would be useful to allow convertion for better interoperability

  • Expr(null: (Int, Int))
    I just do not know, it seems such writing should not occur.

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.

Expr(null:String).show in macro throws NullPointerException
7 participants