值类型与数组

在之前的值类型的一些细节 里说过“值类型在赋值给数组时会被装箱”,这个不严谨,当时上下文是自定义的值类型会被装箱,而系统原生的几个值类型不会,因为它们有默认值。

比如原生的Int,Unit,Double 等,创建好数组后,都以它们的默认值来填充的:

// Int的默认值是0
scala> val arr = new Array[Int](1)
arr: Array[Int] = Array(0)

// Unit的默认值是()
scala> val arr = new Array[Unit](1)
arr: Array[Unit] = Array(())

// Double的默认值是0.0
scala> val arr = new Array[Double](1)
arr: Array[Double] = Array(0.0)

而自定义的值类型,在创建一个数组后,则是用null填充的:

// 定义值类型A
scala> class A(val str:String) extends AnyVal
defined class A

// 创建A类型的数组,默认用null填充
scala> val arr = new Array[A](1)
arr: Array[A] = Array(null)

或许会怀疑上面的例子里,A内部包的是一个String类型,这是个引用类型,null是String的默认值,所以用null填充

// 把A内部换成Int
scala> class A(val i:Int) extends AnyVal
defined class A

// 默认还是用null填充,而不是Int的默认值0
scala> val arr = new Array[A](1)
arr: Array[A] = Array(null)

所以,自定义的值类型数组初始值都是null,与值类型的内部数据无关。
因为自定义的值类型赋给数组时会装箱,它们都是被当作引用来对待的。

但对值类型赋值时是不能用null来赋值的,不管是系统原生的值类型还是自定义的值类型。

// 对刚才的值类型数组的元素赋值
scala> arr(0) = null
<console>:10: error: type mismatch;
 found   : Null(null)
 required: A
          arr(0) = null
                   ^

这点初看有些矛盾,自定义值类型数组,既然都被装箱对待,且初始值为null,却又不允许用null来赋值?

展开看,在数组初始化时它被装箱为引用类型,访问arr(0)时,它是引用类型,值为null;而修改arr(0)时编译器又严格的按照值类型对待,不管它其实已经被装箱为引用类型,不能把null赋给它。 虽然别扭,但对于赋值操作编译器还是保持简单一致的原则。

赋值时把null赋给值类型的变量,编译器无法把Null类型造型为值类型A,这点参考:“Null与Nothing,造型问题” 一文

数组中对值类型元素初始化都做装箱处理,但对var变量默认赋值时却不是这样

scala> class A(val i:Int) extends AnyVal

scala> var a:A = _
a: A = A@0 //默认赋值是 A(0)

scala> a == 0
res0: Boolean = false

scala> a == new A(0)
res1: Boolean = true

默认值是值类型内部数据的默认值, 内部数据如果是引用类型,初始值为A(null),注意在验证时容易碰到scala的一个bug

scala> class A(val s:String) extends AnyVal

// repl下触发scala的一个bug,实际这条语句是没有问题的
scala> var a:A = _  
java.lang.NullPointerException
    at A$.hashCode$extension(<console>:7)
    at A.hashCode(<console>:7)

可以放到文件里编译,运行来看:

class A(val s:String) extends AnyVal

object Main extends App{
  var a:A = _
  println(a == null)
  println(a.s == null)
}

值类型的一些细节

看了老赵的这篇blog: 针对struct对象使用using关键字是否会引起装箱?

对比一下scala里的value class(值类型)来看看scala里对一个value class调用其方法时,是怎么做的。
其实,在之前的这篇 值类型的一些限制 里已经提到过AnyVal文档里的这句话:

by replacing virtual method invocations with static method invocations.(value class用静态方法调用替代了虚方法调用)

不过是否只是在值类型不被装箱的情况下方法调用才会调用静态方法?如果我们对值类型进行装箱,然后对装箱后的对象再调用内部方法,是否还是静态方法调用呢?我们来验证一下。先看看值类型编译后的信息:

// B.scala
class B(val s:String) extends AnyVal {
    def say() { println("hi") }
}

//反编译上面编译后的class,
hongjiang@whj-mbp /tmp % javap B 
Compiled from "B.scala"
public final class B {
  public static boolean equals$extension(java.lang.String, java.lang.Object);
  public static int hashCode$extension(java.lang.String);
  public static void say$extension(java.lang.String);
  public java.lang.String s();
  public void say();
  public int hashCode();
  public boolean equals(java.lang.Object);
  public B(java.lang.String);
}

// 反编译B的伴生对象
hongjiang@whj-mbp /tmp % javap B$
Compiled from "B.scala"
public final class B$ {
  public static final B$ MODULE$;
  public static {};
  public final void say$extension(java.lang.String);
  public final int hashCode$extension(java.lang.String);
  public final boolean equals$extension(java.lang.String, java.lang.Object);
}

在伴生对象里存在几个 $extension 方法,比如say$extension 与伴生类中的say方法是什么关系呢?
(在伴生类中还有几个静态的$extension方法,这几个是伴生对象方法里的静态代理,不用关注)

通过反编译信息可以看到 say 方法其实是调用的半生对象中的say$extension方法:

public void say();
  flags: ACC_PUBLIC
  Code:
  stack=2, locals=1, args_size=1
     0: getstatic     #16                 // Field B$.MODULE$:LB$;
     3: aload_0
     4: invokevirtual #37                 // Method s:()Ljava/lang/String;
     7: invokevirtual #26                 // Method B$.say$extension:(Ljava/lang/String;)V
    10: return
  LocalVariableTable:
    Start  Length  Slot  Name   Signature
           0      11     0  this   LB;
  LineNumberTable:
    line 2: 0

编译时的语法树也很明确的表示say方法调用的是伴生对象里的say$extension :

def say(): Unit = B.say$extension(B.this.s());

equals和hashCode方法也是同理调用伴生对象中的$extension方法,不过这2个方法较特殊,在值类型中是不允许自己实现equals和hashCode的。

现在来看看值类型中的方法在被调用时,是怎么回事:

class B(val s:String) extends AnyVal {
    def say() { println(s) }
}

object Main extends App {
    def boot(b:B) {
        b.say  // 调用值类型的方法 ----> 这行
    }

    boot(new B("hello")) 
}

通过 scalac -Xprint:all 来分析,发现在erasure阶段前,boot方法的定义:

def boot(b: B): Unit = b.say();

posterasure阶段,编译器把上面的方法翻译成了下面的:

def boot(b: String): Unit = B.this.say$extension(b);

注意,编译器在这一步处理后boot传入的是String,而不是值类型B,等于剥掉了值类型这层wrapper,直接用的B内部的数据;并且方法体被替代成了B.this.say$extension(b) 原本的虚方法调用也变成了对final方法的调用。

所以因为编译器在编译时就对方法体做了替换,使得运行时压根就不会出现虚方法调用,也不会因为方法调用而产生装箱的情况。

再看 boot(new B("hello")) 这一句,因为boot方法最后被翻译为接受String类型的方法,所以这一句在编译时也被翻译成了下面的语句:

boot("hello")  // new B("hello") 被编译器剥掉值类型的壳,直接传入"hello"

那在什么情况下值类型会被装箱呢?

1) 赋给另一个类型的变量

$ cat A.scala
class A(val s:String) extends AnyVal
class Main {
  val a:Any = new A("hello") // 变量a指定为Any类型
  val b = new A("world")     // 类型推导
  val c:Any = b               // 把b赋给Any类型的c
}

$ scalac -Xprint:jvm A.scala
...
Main.this.a = new A("hello");        // 引用类型
Main.this.b = "world";               // unboxing
Main.this.c = new A(Main.this.b()); // boxing
...

2) 赋给数组

val arr = new Array[A](1)
val a = new A("aaa")
arr(0) = a

// 上面的语句翻译后,实际调用的是update方法:
arr.update(0, new A(Main.this.a())) // boxing

3) 运行时的类型测试

b.isInstanceOf[A]

//或进行类型模式匹配,都会引起装箱
b match { case x:A => println("ok") }