Dealing with Java nulls and value classes

I deal a lot with Java’s concurrent collections, including their null-returning methods, like poll. Consider this (non-concurrent) example:

val q = ju.concurrent.ConcurrentLinkedQueue[Int]()

val a: Int = q.poll() // 0
val b: Any = q.poll() // null

// q.poll() == null // rejected
// null == q.poll() // rejected
// null eq q.poll() // rejected

val c = q.poll() eq null // false

My first question is: Is this the expected behavior? It all seems pretty dangerous. I would have expected the evaluation of a to cause an NPE, and the test q.poll() eq null to be true. As it is, I worry about differentiating an empty queue from a queue that starts with 0.

My second question is: What are the safe usage patterns? Option and pattern-matching seem to be fine, but I’m still nervous:

val d: Option[Int] = Option(q.poll()) // None

q.add(0)
val e: Option[Int] = Option(q.poll()) // Some(0)

def drain[A](q: ju.Queue[A]): List[A] = q.poll() match
   case null  => Nil
   case value => value :: drain(q)

q.add(0)
val f = drain(q) // List(0)

I wouldn’t want to hit a weird case where, say, Option(q.poll()) evaluates to Some(0) on an empty queue, or to None on a queue that starts with 0…

Scastie: https://scastie.scala-lang.org/GWx2h2b7SD67SOL6vM7y7w

1 Like

Yes and yes. The Int declaration maps to java.lang.Integer on the Java side - the queue will contain Integer values. Receiving an Integer value where an Int is expected (which will be the case for #poll() results, unless you explicitly widen the expected type) will trigger an implicit conversion that resolves to a call to #instanceOf[Int]. For null values, this will yield the default value which is defined to be 0 for Int. At least that’s my understanding of the mechanics.

Option#apply() should be fine, I’d think. Still, I’d probably want to put the queue behind a minimalist, app specific facade, operate the queue in terms of Integer (or some app specific AnyRef wrapper around Int) and explicitly handle conversions to and fro in the facade methods.

2 Likes

My main concern is not so much with a Queue[Int], but more with a Queue[A] when A (which had been erased) happens to be Int. Code like null.asInstanceOf[A] can then become 0, losing the information that Java is conveying through null.

If q is empty, this Java code throws an exception:

int a = q.poll();

Maybe I’d be happier if Scala did the same, instead of happily giving me a value out of an empty queue.

Should be fine for A as for Int, as long as you ensure the null case is resolved first (e.g. via Option#apply() in a facade).

From a quick glance at generated bytecode: Technically, the conversion from null to 0 is forced by unboxing. #poll() returns a ref value - if forced to an Int, an #unboxToInt() conversion (respecting the default value spec for #asInstanceOf) is triggered. If you Option#apply() first, you resolve the null, but don’t trigger unboxing. (The latter still may happen later, e.g. for Option(ConcurrentLinkedQueue[Int]().poll()).get + 1.)

I still like the idea, as it expresses what’s really going on, but it doesn’t easily blend in with a generic queue/facade representation in A, of course, and I haven’t found a way around this when going from Integer to Option[Int]:

INVOKEVIRTUAL scala/Predef$.Integer2int (Ljava/lang/Integer;)I
INVOKESTATIC scala/runtime/BoxesRunTime.boxToInteger (I)Ljava/lang/Integer;
INVOKEVIRTUAL scala/Option$.apply (Ljava/lang/Object;)Lscala/Option;

Probably - but it doesn’t, and if it did, somebody else would likely be unhappy. :slight_smile: The best one can do is to encapsulate fragile behavior in a single location at the outermost boundary, as is good practice for anything Java interop, anyway.

2 Likes