Pattern for Map.withDefault, but only if it doesn't already have one?

In my use case, I allow users to pass a Map as a lookup table of cases (doh). Now, switch expression logic is in common need of handling default cases. It seems that Map.withDefault and Map.withDefaultValue should cover this in a manner transparent to my code. My problem is that I have a sort of second choice default value I want to use if the user provided Map does not have custom default handling (is not defined on the whole domain). Unfortunately, both of the former methods apply only to, nomen omen, apply, and there is no way to poll this other than extremely ugly, brittle tricks like checking map.getClass.getName. What I need is code equivalent to getOrElse or get as if the default value applied to these methods. I do not want to replace or override pre existing defaults, just to provide my own if the map does not define a default and a lookup is a miss.

It seems like I’m stuck with calling apply and catching NoSuchElementException. However, aside of the design issue that, in my case, lack of a default value is not some kind of an error and would be better of handled explicitly, throwing an exception is several orders of magnitude more expensive than get or getOrElse (actually, the expensive part is the filling of the stack trace in the exception - of which a large part is executed lazily, but JVM still needs to populate an internal field with ‘non java’ raw stack trace).

Any ideas how to handle it elegantly, with minimal increse of the interface surface? I don’t want to require my own MapPossiblyWithDefault and I’d rather avoid explicitly adding a ‘default case’ entry as an additional parameter to the Map.

withDefault & withDefaultValue are usually considered bad practices, precisely because they would get attached to the whole life of the map and no way to tell if they already have a default or no.

The best would be to just use getOrElse and allow the user to pass or not a default like this:

def f(lookup: Map[Foo, Bar], default: Option[Bar] = None) = {
  def look(key: Foo): Bar =
    lookup.getOrElse(key, default.getOrElse(secondChoice))
}

And inside your function always call look to get a value from the map.

Not sure whether this makes sense at all for your scenario, but perhaps you could lift the defaults aspect away from Map into [Partial]Function.

def withDefaultPartial[K, V](default: V)(pf: PartialFunction[K, V]): PartialFunction[K, V] =
  pf.applyOrElse(_, Function.const(default))

def withDefaultTotal[K, V](default: V)(pf: PartialFunction[K, V]): Function[K, V] =
  withDefaultPartial(default)(pf)

// user provided, possibly with default
val ma = Map("a" -> "b", "c" -> "d")
val mb = withDefaultPartial("x")(ma)

// internal wrapup with 2nd level default
val fa = withDefaultTotal("y")(ma)
val fb = withDefaultTotal("z")(mb)

I’m not sure why it’s not a bug that the map with default doesn’t use the default in applyOrElse.

scala> val vs = Map(1 -> "one")
val vs: scala.collection.immutable.Map[Int,String] = Map(1 -> one)

scala> val defaulted = vs.withDefault(i => i.toString)
val defaulted: scala.collection.immutable.Map[Int,String] = Map(1 -> one)

scala> vs(-10
     | )
java.util.NoSuchElementException: key not found: -10
  at scala.collection.immutable.Map$Map1.apply(Map.scala:245)
  ... 32 elided

scala> defaulted(-1)
val res1: String = -1

scala> vs.applyOrElse(-1, (i: Int) => "nope")
val res2: String = nope

scala> defaulted.applyOrElse(-1, (i: Int) => "nope")
val res3: String = nope

scala> vs.isDefinedAt(-1)
val res4: Boolean = false

scala> defaulted.isDefinedAt(-1)
val res5: Boolean = false

It’s not actually true that you can’t detect that a map has a default:

scala> defaulted match { case x: scala.collection.immutable.Map.WithDefault[_,_] => }
1 Like

Matching with Map.WithDefault is ugly (I don’t even know why this type is public). Also, any decorator implementation would fail this match, not mentioning potential custom Maps.

I agree though that it would be more intuitive if applyOrElse used the default value. Probably impossible to change now due to all the existing code…