没有踩过mapValues的坑之前,我相信大多数人会认为mapValues和所有其他map类方法的逻辑是一样的:对Map里所有的value施加一个map函数,返回一个新的Map。但实际情况却并不这么简单,还是先看一段“诡异”的代码吧 (本文原文出处: 本文原文链接: http://blog.csdn.net/bluishglc/article/details/80156218 转载请注明出处。):
object Main extends App{
class A {
def this(f: String) = {
this()
this.f = f
}
var f: String = _
override def toString = s"A(${f})"
}
val m = Map(1 -> new A("a"))
println("-------------------------")
println(s"m=$m")
println("-------------------------")
val m1 = m.mapValues{a=>a.f = "b";a}
println(s"m=$m")
println(s"m1=$m1")
m1.foreach(_._2.f = "c")
println(s"m1=$m1")
println("-------------------------")
val m2 = m.transform{(k,v)=>v.f = "d";v}
println(s"m=$m")
println(s"m2=$m2")
println("-------------------------")
}
程序输出:
-------------------------
m=Map(1 -> A(a))
-------------------------
m=Map(1 -> A(a))
m1=Map(1 -> A(b))
m1=Map(1 -> A(b))
-------------------------
m=Map(1 -> A(d))
m2=Map(1 -> A(d))
-------------------------
这段代码的“诡异”之处是在完成m1.foreach(_._2.f = "c")
之后打印m1时,输出的竟然是m1=Map(1 -> A(b))
,仅从效果上看似乎foreach操作根本没有执行过。而真正有效的实现是后面的transform的版本。(注: mapValues和transform往往是将集合元素转换为某一个类型的值,本例我们为了简化演示示例,让它们都返回了原来的类型。)
这确实让人费解,并且会让很多使用mapValues的程序员“入坑”,因为这个事例中对mapValues的使用意图是很有普遍性和代表性的:即将原有map通过mapValues转换为新的map之后继续在上面进行其他的操作,但是这里被mapValues返回的m1似乎永远停留在了它在第一次赋值时的那个状态!
那么到底背后发生了什么了呢?我们在m.mapValues
和m1.foreach
在迭代过程中打印一些信息来看看能不能发现一些问题:
object Main extends App{
class A {
def this(f: String) = {
this()
this.f = f
}
var f: String = _
override def toString = s"A(${f})"
}
val m = Map(1 -> new A("a"))
println("-------------------------")
println(s"m=$m")
println("-------------------------")
val m1 = m.mapValues{a=>a.f = "b";println("1:"+a.f);a}
println(s"m=$m")
println(s"m1=$m1")
m1.foreach{kv=>kv._2.f = "c";println("2:"+kv._2.f)}
println(s"m1=$m1")
println("-------------------------")
val m2 = m.transform{(k,v)=>v.f = "d";v}
println(s"m=$m")
println(s"m2=$m2")
println("-------------------------")
}
程序输出:
-------------------------
m=Map(1 -> A(a))
-------------------------
m=Map(1 -> A(a))
1:b
m1=Map(1 -> A(b))
1:b
2:c
1:b
m1=Map(1 -> A(b))
-------------------------
m=Map(1 -> A(d))
m2=Map(1 -> A(d))
-------------------------
添加打印语句之后,透露了诡秘之处的些许原因,关键点是在m1.foreach
完成之后打印m1之前输出的三行:
1:b
2:c
1:b
显然2:c
是在foreach时打印的,那么前后两个1:b是什么情况呢?到这里,我们需要深入了解一下mapValues了,Scala的API文档里对mapValues的返回值是这样说明的:
a map view which maps every key of this map to f(this(key)). The resulting map wraps the original map without copying any elements.
这里的关键点是view,既然是view,那么所有的transformation的操作就都是lazy的。关于Scala集合的View,请参考官方文档:https://docs.scala-lang.org/overviews/collections/views.html , 本文不做过多赘述,总之mapValues返回的这个结果是一个view, 提供给它的那个转换函数 f(this(key))总是在需要实际读取值时才会被触发执行,那我们来解释一下前后两个1:b是怎么回事:第一个是在m1调用foreach时触发的,因为此时需要实际读取m1中的值了,所以mapValues将会被关联性地触发,从当前状态的m通过mapValues得到了m1,这是第一个1:b的来历。在m1.foreach
的操作过程中,集合m元素的字段f确确实实地被改成了b, 但是,有趣而糟糕的是,在其之后打打印m1的过程中,m1作为一个view, 任然会重复前面的动作,就是从当前的集合m中应用mapValues而确定每一个并打印出来,所以mapValues又被执行了一次,这是第二个1:b的来由,而这样一来,集合m元素的字段f又从c被设置回了b!也就是说:在这个示例里,不管你对m或m1的元素做什么样的修改,在试图通过m1来读取它的元素的那一刻,所有的元素都会被重置回b!
为了更加清楚地验证这一点,我们给类A再加一个字段g, 让f依然保持在mapValues中被修改,g通过m或m1来修改,我们看一下会发生什么:
object Main extends App{
class A {
def this(f: String, g: String) = {
this()
this.f = f
this.g = g
}
var f: String = _
var g: String = _
override def toString = s"A(f=${f}, g=${g})"
}
val m = Map(1 -> new A("a","a"))
println("-------------------------")
println(s"m=$m")
println("-------------------------")
val m1 = m.mapValues{a=>a.f = "b";println("1:"+a.f);a}
println(s"m=$m")
println(s"m1=$m1")
m1.foreach{kv=>kv._2.f = "c";println("2:"+kv._2.f)}
println(s"m1=$m1")
println("-------------------------")
m.foreach{kv=>kv._2.f = "e";kv._2.g = "e"}
println(s"m=$m")
println(s"m1=$m1")
println("-------------------------")
m1.foreach{kv=>kv._2.f = "f";kv._2.g = "f";println("2:"+kv._2.f)}
println(s"m=$m")
println(s"m1=$m1")
println("-------------------------")
}
程序输出:
-------------------------
m=Map(1 -> A(f=a, g=a))
-------------------------
m=Map(1 -> A(f=a, g=a))
1:b
m1=Map(1 -> A(f=b, g=a))
1:b
2:c
1:b
m1=Map(1 -> A(f=b, g=a))
-------------------------
m=Map(1 -> A(f=e, g=e))
1:b
m1=Map(1 -> A(f=b, g=e))
-------------------------
1:b
2:f
m=Map(1 -> A(f=f, g=f))
1:b
m1=Map(1 -> A(f=b, g=f))
-------------------------
在这个对比明显的示例中,我们可以看到,不管我们如何的折腾,集合元素的f字段被死死的锁定为b,当然,用锁定并不准确,而是每次试图读取值时都会被重新刷回b,但是对于字段g则完全不同,不管是是通过m还是m1,对g的任何修改都是有效的!
所以总结一下这个诡异问题的根源就是:mapValues创建的map的view总是lazy的,如果mapValues的操作涉及到改写元素值的操作,要特别小心,因为在每次引用到这个view时,mapValues都会被重新执行,在次之前的任何修改都会被覆盖!这是mapValues最糟糕的地方!如果你不希望这种情况发生(显然大多数情况下你是不希望这样做的),那么你需要使用的是transform而不是mapValues.
最后提一下,scala集合在mapValues上的设计确实有瑕疵,倒不是因为mapValues的实现逻辑有问题,而是mapValues与其他Map的操作有质的区别,它是切换成View模式进行工作的一个方法,而Scala在API层面上没有给到使用者足够的提示!为此,有人建议Scala能校方SeqView为Map设立一个MapView,使用者应该显示的通过Map.view切换到一个view上再通过view.mapValues这样的方式来调用,这会避免使用者因为不了解mapValues的特性而导致错误地使用了该方法,这个请求已经作为一个bug被log给了Scala官方:https://issues.scala-lang.org/browse/SI-4776