Skip to content
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

Make Java Map wrappers handle nulls according to put/remove contract #9344

Merged
merged 1 commit into from Jan 7, 2021

Conversation

som-snytt
Copy link
Contributor

@som-snytt som-snytt commented Nov 25, 2020

If map contains key, always return nonEmpty.
If binding to null, return Some(null) instead of None.

Fixes scala/bug#11894

@scala-jenkins scala-jenkins added this to the 2.13.5 milestone Nov 25, 2020
Copy link
Member

@lrytz lrytz left a comment

Choose a reason for hiding this comment

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

This looks consistent with the existing implementation of get above, so LGTM.

@ansvonwa could you also take a look?

@SethTisue SethTisue changed the title Jave Map wrapper respects put/remove contract Java Map wrapper respects put/remove contract Nov 25, 2020
@SethTisue SethTisue added the library:collections PRs involving changes to the standard collection library label Nov 25, 2020
@SethTisue
Copy link
Member

any collections-crew opinions here? @Ichoran @NthPortal @joshlemer — we should make a team for y'all for easy @-mentioning

@Ichoran
Copy link
Contributor

Ichoran commented Nov 25, 2020

LGTM.

If we are content with not passing on every call to remove when there is no key, remove would be more efficiently and logically written (pseudocode)

if (containsKey(k)) Some(remove(k)) else None

But the change to the logic is good either way.

@som-snytt
Copy link
Contributor Author

In my cut and pastey haste, I forgot my previous point that, if there's a race, checking contains can falsely return Some(null); it would be better to get the current value to return before applying the mutation.

@som-snytt
Copy link
Contributor Author

The collections-crew could be called The Collective.

@ansvonwa
Copy link
Contributor

And just for completeness, there is another place where this could be changed:
In JConcurrentMapWrapper the methods get, putIfAbsent and replace all treat the entry as absent if the key is present but the value is null.

This may in theory be the case, as the java concurrent maps explicitly (by a comment) allow maps handling null values. It is just the case that none of the maps in the java standard library does. And the wrapper around a scala concurrent map obtained by asJava is removed anyway by a call of asScala, so not even back-and-forth-conversions like asScala(asJava(new concurrent.TrieMap())) pose a problem.
So the only way to get this wrong result is to use a custom (java) map that can handle nulls and convert that to scala via asScala. I think this case is relatively rare, so I would be fine with not changing it for the benefit of keeping performance.

If we would change this, every call of get, putIfAbsent and replace in JConcurrentMapWrapper would require an additional call of containsKey, so take about twice the time.

What do you guys think?

@ansvonwa
Copy link
Contributor

ansvonwa commented Nov 25, 2020

In my cut and pastey haste, I forgot my previous point that, if there's a race, checking contains can falsely return Some(null); it would be better to get the current value to return before applying the mutation.

Not sure if that is better. Maybe get itself already has this problem?

    def get(k: K) = {
// Assume k is not (yet) in the map
      val v = underlying get k
// What if, here, between these lines, another thread puts a value for key k in the map?
      if (v != null)
        Some(v)
      else if (underlying containsKey k)
        Some(null.asInstanceOf[V])
      else
        None
    }

If another thread puts a value between the calls of get and containsKey, we would get Some(null) instead of either None or Some(value). Right?

@som-snytt
Copy link
Contributor Author

Right, something like compute which tells you what the current value is.

I'll spend a few minutes with the issue of other methods, thanks for your comment. I did spend a few minutes with Hashtable and Properties. In a way, it's nice to spend time with API I never use any more.

@Ichoran
Copy link
Contributor

Ichoran commented Nov 25, 2020

Maps cannot be safely used concurrently, so I wouldn't worry about that too much. The logic just needs to be right for non-concurrent access. (Unless it's a map that guarantees correct operation during concurrency.)

@som-snytt
Copy link
Contributor Author

som-snytt commented Nov 25, 2020

Collections.synchronizedMap(m).asScala seems easy, like an unlocked gun cabinet.

They could have a footgun app where instead of import language.selfHarm, you'd have to use 2FA to unlock the feature.

With a 48 hour cooling off period!

@joshlemer
Copy link
Member

any collections-crew opinions here? @Ichoran @NthPortal @joshlemer — we should make a team for y'all for easy @-mentioning

import scala.jdk.CollectionConversers._

@SethTisue
Copy link
Member

SethTisue commented Nov 26, 2020

created https://github.com/orgs/scala/teams/collections/members

@-mention as @scala/collections

happy to add anyone else — I tend to assume that other folks like @som-snytt and the team here at Lightbend see everything anyway

@NthPortal
Copy link
Contributor

I don't like it—in fact, I hate it—but I agree that it's probably the best solution

If map contains key, always return nonEmpty.
If binding to null, return Some(null) instead of None.
This behavior is consistent with get, which is the basis
for getOrElse, lift, and unapply.
@som-snytt
Copy link
Contributor Author

Pushed Ichoran's tweek. (Visual pun alert on geek.)

I reviewed ansvonwa's comment about whether more is desirable, especially in light of Ichoran's comment about concurrency.

My conclusion is that, as a convenience feature, it's enough to 1. ignore concurrency in the normal wrapper and 2. assume JDK implementations in the concurrent case, where null keys and values are disallowed.

I added a comment that consistency with get is the desideratum, because get is the basis for getOrElse, life, and unapply.

That means val m(x) = k throws on absent key but yields null on null value.

A map of boxed Double with a null value will unbox to 0.0.

@som-snytt
Copy link
Contributor Author

som-snytt commented Nov 27, 2020

Despite Because of a layer of Thanksgiving turkey brain fog overlaying the pandemic fog, I wondered if this is worth it:

    // support Some(null) if currently bound to null
    override def put(k: K, v: V): Option[V] =
      if (v == null) {
        val present = underlying.containsKey(k)
        val result  = underlying.put(k, v)
        if (present) Some(result) else None
      } else {
        var result: Option[V] = None
        def recompute(k0: K, v0: V): V = v.tap(_ =>
          if (v0 != null) result = Some(v0)
          else if (underlying.containsKey(k0)) result = Some(null.asInstanceOf[V])
        )
        underlying.compute(k, recompute)
        result
      }

    override def update(k: K, v: V): Unit = underlying.put(k, v)

    // support Some(null) if currently bound to null
    override def remove(k: K): Option[V] = {
      var result: Option[V] = None
      def recompute(k0: K, v0: V): V = {
        if (v0 != null) result = Some(v0)
        else if (underlying.containsKey(k0)) result = Some(null.asInstanceOf[V])
        null.asInstanceOf[V]
      }
      underlying.compute(k, recompute)
      result
    }

You can't compute a null value, but you can put it.

I probably won't push this change. Tired of waiting for the green check on the current PR.

@dwijnand dwijnand requested a review from Ichoran December 4, 2020 16:55
@dwijnand
Copy link
Member

dwijnand commented Jan 6, 2021

This is still needs a review. @Ichoran or anyone in @scala/collections care to look at this, please?

@som-snytt som-snytt closed this Jan 6, 2021
@SethTisue
Copy link
Member

okay... somebody else want to adopt this?

@Ichoran
Copy link
Contributor

Ichoran commented Jan 6, 2021

I'm not really around enough to reliably review things any more. I agree that this is the way to do it--it's a good implementation if we think that a double-lookup is worth it for correctness.

For what it's worth, Java itself decided it wasn't; all the methods added for JDK 8 treated mapping to null the same as no mapping. I don't use wrapped Java maps personally, though, so I don't have a strong opinion either way.

So, if someone decides we want this, then it can go in as is, no changes needed. If we decide we don't, then there's nothing to do.

@SethTisue
Copy link
Member

thanks, Rex. we will still welcome your reviews if/when we can get them — but, noted that we shouldn't wait around too long if we haven't heard from you.

@lrytz
Copy link
Member

lrytz commented Jan 7, 2021

I'm in favor of this change as it makes put/remove consistent with get.

@dwijnand dwijnand reopened this Jan 7, 2021
@dwijnand dwijnand merged commit 05d9531 into scala:2.13.x Jan 7, 2021
@SethTisue SethTisue added the release-notes worth highlighting in next release notes label Jan 7, 2021
@SethTisue SethTisue changed the title Java Map wrapper respects put/remove contract Make Java Map wrappers respect put/remove contract Feb 19, 2021
@SethTisue SethTisue changed the title Make Java Map wrappers respect put/remove contract Make Java Map wrappers handle nulls according to put/remove contract Feb 19, 2021
@som-snytt som-snytt deleted the issue/11894-wrap branch June 27, 2021 09:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
library:collections PRs involving changes to the standard collection library release-notes worth highlighting in next release notes
Projects
None yet
9 participants