I was working with some complicated generics system, and I found the need to make an abstraction for a typesafe mapping between K[T] and V[T] where T is the same for individual pairs.
In the case where the key or the value is T itself, we use a trick: type Identity[T] = T
, and set the parameter to Identity
. I have provided type aliases to make this usage more clear.
I would appreciate critiques on how I can make my code more robust, elegant, efficient, or idiomatic
/**
* A map where the keys and values accept a type parameter, with a bound, and key-value pairs in the map have the same
* type parameter.
*/
class TypeMatchingMap[K[_ <: B], V[_ <: B], B](private val contents: Map[Any, Any]) {
def +[T <: B](kv: (K[T], V[T])): TypeMatchingMap[K, V, B] =
new TypeMatchingMap[K, V, B](contents + kv)
def -(k: K[_]): TypeMatchingMap[K, V, B] =
new TypeMatchingMap[K, V, B](contents - k)
def apply[T <: B](k: K[T]): V[T] =
contents(k).asInstanceOf[V[T]]
def get[T <: B](k: K[T]): Option[V[T]] =
contents.get(k).map(_.asInstanceOf[V[T]])
type Pair[T <: B] = (K[T], V[T])
def toSeq: Seq[Pair[_]] =
contents.toSeq.map(_.asInstanceOf[Pair[_]])
override def toString: String = contents.toString
override def equals(obj: scala.Any): Boolean =
if (obj.isInstanceOf[TypeMatchingMap[Identity, Identity, _]])
obj.asInstanceOf[TypeMatchingMap[Identity, Identity, Any]].contents == this.contents
else false
override def hashCode(): Int = contents.hashCode()
}
object TypeMatchingMap {
type Identity[T] = T
type Identified[K[_ <: B], B] = TypeMatchingMap[K, Identity, B]
type Identifying[V[_ <: B], B] = TypeMatchingMap[Identity, V, B]
private val _empty = new TypeMatchingMap[Identity, Identity, Any](Map.empty)
def empty[K[_ <: B], V[_ <: B], B]: TypeMatchingMap[K, V, B] = _empty.asInstanceOf[TypeMatchingMap[K, V, B]]
}
object TypeMatchingMapTest extends App {
sealed trait NumBox
type BoxID[T <: NumBox] = UUID
case class FloatBox(n: Float) extends NumBox
case class DoubleBox(n: Double) extends NumBox
case class IntBox(n: Int) extends NumBox
var map: Identified[BoxID, NumBox] = TypeMatchingMap.empty[BoxID, Identity, NumBox]
val id5f: BoxID[FloatBox] = UUID.randomUUID()
map = map + (id5f -> FloatBox(5f))
val id6d: BoxID[DoubleBox] = UUID.randomUUID()
map = map + (id6d -> DoubleBox(6.0))
val id7i: BoxID[IntBox] = UUID.randomUUID()
map = map + (id7i -> IntBox(7))
val f5: FloatBox = map(id5f)
val d6: DoubleBox = map(id6d)
val i7: IntBox = map(id7i)
println(map)
println(map.toSeq)
}
A use case? Say you have a map of entities, which are organized by UUIDs. The problem is that the type system does now acknowledge what kind of Entity an EntityID is pointing to. So, you can make a parametrized alias:
type EntityID[E <: Entity] = UUID
And store your entities in an Identified[EntityID, Entity]
. Then, if you have, say an EntityID[Cow]
, you can pass it to the map and get your Cow
.
Thank you, and for your troubles, a related image for your enjoyment:
1 Answer 1
It's been a couple of months so maybe you've moved on, but I had a look through this today. I also may have read too much into your example use case, so apologies if this isn't helpful.
If like in your example we can restrict the scope down to cases where K is covariant, and we can also remove the higher-kindedness of the value type, then we can improve the type safety significantly. So much so that we can escape all of the asInstanceOf
calls!
class TypeMatchingMap[K[+_ <: B], B](private val contents: Map[K[B], B]) {
def +[T <: B](kv: (K[T], T)): TypeMatchingMap[K, B] =
new TypeMatchingMap[K, B](contents + kv)
def -[T <: B](k: K[T]): TypeMatchingMap[K, B] =
new TypeMatchingMap[K, B](contents - k)
def apply[T <: B : ClassTag](k: K[T]): T =
contents(k) match {
case t: T => t
case _ => throw ??? // Insert throwable here
}
def get[T <: B : ClassTag](k: K[T]): Option[T] =
contents.get(k).flatMap {
case t: T => Some(t)
case _ => None
}
def toSeq: Seq[(K[B], B)] = contents.toSeq
override def toString: String = contents.toString()
override def equals(obj: Any): Boolean =
obj match {
case m: TypeMatchingMap[_, _] => m.contents == this.contents
case _ => false
}
override def hashCode(): Int = contents.hashCode()
}
object TypeMatchingMap {
def empty[K[+_ <: B], B]: TypeMatchingMap[K, B] =
new TypeMatchingMap[K, B](Map.empty)
}
object TypeMatchingMapTest extends App {
sealed trait NumBox
type BoxID[+T <: NumBox] = UUID
case class FloatBox(n: Float) extends NumBox
case class DoubleBox(n: Double) extends NumBox
case class IntBox(n: Int) extends NumBox
var map: TypeMatchingMap[BoxID, NumBox] = TypeMatchingMap.empty[BoxID, NumBox]
val id5f: BoxID[FloatBox] = UUID.randomUUID()
map = map + (id5f -> FloatBox(5f))
val id6d: BoxID[DoubleBox] = UUID.randomUUID()
map = map + (id6d -> DoubleBox(6.0))
val id7i: BoxID[IntBox] = UUID.randomUUID()
map = map + (id7i -> IntBox(7))
val f5: FloatBox = map(id5f)
val d6: DoubleBox = map(id6d)
val i7: IntBox = map(id7i)
println(map)
println(map.toSeq)
}
If you can't make the assumptions I've made above, I'd still recommend changing the signature of -
to def -[T <: B](k: K[T]): TypeMatchingMap[K, V, B]
. Consider the following:
val map: TypeMatchingMap[K, V, Int] = ???
val k: K[String] = ???
map - k
We know that the last line won't ever do anything useful, so in my mind it would be kinder to prevent it from compiling so as to prevent any client of the API from making a mistake.
Happy to discuss the code more, but didn't want to go into too much detail if you're no longer interested.
Explore related questions
See similar questions with these tags.