4
votes

I'm running into some kind of quirk in the Scala type system that has me a bit stumped. I am trying to make a class that extends Map[String,String] and I can't quite figure out how to implement the + method in such a way that the compiler accepts it.

Here's the code I have now:

class ParamMap(val pairs:List[(String,String)] = Nil) extends Map[String,String] {

  lazy val keyLookup = Map() ++ pairs

  override def get(key: String): Option[String] = keyLookup.get(key)
  override def iterator: Iterator[(String, String)] = pairs.reverseIterator

  /**
   * Add a key/value pair to the map
   */
  override def + [B1 >: String](kv: (String, B1)) = new ParamMap(kv :: pairs)

  /**
   * Remove all values for the given key from the map
   */
  override def -(key: String): ParamMap  = new ParamMap(pairs.filterNot(_._1 == key))

  /**
   * Remove a specific pair from the map
   */
  def -(kv: (String, String)) : ParamMap = new ParamMap(pairs - kv)
}

Scala tells me this:

type mismatch;  found: (String, B1)  required: (String, String)

I believe this is because B1 is allowed to be a subtype of String but my constructor expects just a String (?). My original attempt was:

override def +(kv: (String, String)) = new ParamMap(kv :: pairs)

But this complained because the type signature didn't match the trait:

class ParamMap needs to be abstract, since method + in trait Map of type [B1 >: String](kv: (String, B1))scala.collection.immutable.Map[String,B1] is not defined
method + overrides nothing

I'm new to Scala and I think I'm getting over my head here in terms of how the type system works. Perhaps I'll try messing with casting but I have a feeling there might be a "better way" that, if I know it, will save me a lot of trouble in the future.

Any ideas?

4

4 Answers

7
votes

Some background about Scala's type system.

  • The syntax B1 >: String means that B1 is a supertype of String. So B1 is less specific, and can't be cast to a String. Conversely, B1 <: String would be a subtype relationship.

  • The definition of the Map trait is Map [A, +B], where A represents the type of the key and B the type of the value. The +B notation says that Map is covariant in the key type, which means that T <: S implies Map[A, T] <: Map[A, S].

  • The full type of the Map.+ method is + [B1 >: B] (kv: (A, B1)): Map[A, B1]. The covariance of B kind of forces the use of B1 >: B. Here's an example of how it works: given a map m: Map[String, String] adding a key-value pair with a less specific type kv : (String, Any) will result in a less specific map, (m + kv): Map[String, Any].

The last point illustrates the problem with your ParamMap definition. According to the Map interface, one should be able to add a key of type Any to a map of type ParamMap <: Map[String, String] and get back a Map[String, Any]. But you're trying to define ParamMap.+ to always return ParamMap[String, String], which is incompatible with Map.+.

One way to fix the problem is to give ParamMap an explicit type parameter, something like (warning untested),

class ParamMap[B](val pairs:List[(String,String)] = Nil) extends Map[String, B] {
  ...
  override def + [B1 >: B](kv: (String, B1)) = new ParamMap[B1](kv :: pairs)
}

but this may not be what you want. I don't think there's a way to fix the value type as String and implement the Map[String, String] interface.


Given all the above, why does the code in your answer compile? You've actually uncovered a limitation (unsoundness) of Scala's pattern matching, and it can lead to run-time crashes. Here's a simplified example:

def foo [B1 >: String](x: B1): Int = {
  val (s1: Int, s2: Int) = (x, x)
  s1
}

Although this compiles, it doesn't do anything useful. In fact, it will always crash with a MatchError:

scala> foo("hello")
scala.MatchError: (hello,hello) (of class scala.Tuple2)
    at .foo(<console>:9)
    at .<init>(<console>:10)
    at .<clinit>(<console>)
    ...

In your answer, you've basically told the compiler to convert a B1 instance to a String, and if the conversion doesn't work, you'll get a runtime crash. It's equivalent to an unsafe cast,

(value: B1).asInstanceOf[String]
4
votes

You're correct that your constructor expects a value of type List[String, String], but the issue isn't that B1 could be a subclass of String, it's that it could be a superclass -- this is what the B1 :> String notation indicates.

At first glance, you might wonder why the parent Map class would have the method typed this way. In fact, the return type of the + method you're attempting to override there is Map[String, B1]. In the context of a general map, though, this makes sense. Suppose you had the following code:

class Parent
class Child extends Parent

val childMap = Map[String, Child]("Key" -> new Child)

val result = childMap + ("ParentKey" -> new Parent)

The type of result would then have to be Map[String, Parent]. In light of this, the type restrictions on the + method in Map makes sense, but your fixed-type map isn't capable of fulfilling what the method is designed to be able to do. Its signature allows you to pass in a value of e.g. type (String, AnyRef), but using the method definition you gave in your followup answer, you'll get a MatchError when it tries to perform the assignment to key and value.

Does that make sense?

2
votes

I ran the same problem with a colleague, when trying to build a Bag[T] which is a Map[T,Int]. We found two different solutions:

  1. Implement Traversable rather than Map with appropriate Builder and CanBuildFrom, and add the useful map methods (get,+,-). If you need to pass the collection to a function taking maps as arguments, you can use implicit conversions. Here is our full Bag implementation: https://gist.github.com/1136259

  2. Stay simple:

    object collection {
      type ParamMap = Map[String,String]
      object ParamMap {
        def apply( pairs: List[(String,String)] = Nil ) = Map( pairs:_* )
      }
    }
    
0
votes

The compiler does seem to accept this one:

  override def + [B1 >: String](kv: (String, B1)) = {
    val (key:String, value:String) = kv
    new ParamMap((key,value) :: pairs)
  }

But I don't know why that is better than the original. I suppose this is an acceptable solution if nobody has a better one.