scala雾中风景(25): try-finally表达式的类型推导

一段实际代码简化后如下:

class A {
  def foo():String = {
    try{
      Thread.sleep(1000)
    }finally{
      "ok"
    }
  }
}

本来期望这个foo方法返回finally块中的”ok”,但编译的时候却给出了类型不匹配的错误:

 ➜  scalac A.scala
A.scala:4: error: type mismatch;
 found   : Unit
 required: String
      Thread.sleep(1000)
                  ^

按说scala类型推断这么强大,不应该推断不出最终的返回值类型,从编译器的错误来看似乎它非要求在try代码块里最后一行表达式必须也是String类型的值,为什么finally里的表达式没有参与类型推断呢?

把上述代码稍作改动,在try代码块里明确的给出一个String结果

def foo():String = {
    try{
        Thread.sleep(1000)
        "res"
    }finally{
        "ok"
    }
}

再编译一下,却给出了一行警告:

 ➜  scalac A.scala
A.scala:7: warning: a pure expression does nothing in statement position; you may be omitting necessary parentheses
    "ok"
    ^
one warning found

分析一下为什么编译器认为这句表达式”does nothing”,而不把它当作返回值对待:

 ➜  scalac -Xprint:typer A.scala
 ...

def foo(): String = try {
  java.this.lang.Thread.sleep(1000L);
  "res"
} finally {
  "ok";
  ()
}

看到编译器在finally块的”ok”表达式后边自行增加了一个返回Unit类型的值(),看上去编译器认为finally块里的逻辑是一个“procedure”,一定要满足Unit

从scala语言规范来看,try-catch-finally表达式也是有返回值的,且返回值主要是取决于trycatch里的最后一行表达式,而finally被认为是做一些收尾的工作的,不应该在里面去改变返回结果。

具体到这个案例,foo方法声明的返回值类型是Stringfoo方法体里的try-finally表达式的值就是最终的返回值,而try-finally表达式的值是取决于try代码块里的最后一行表达式,而非finally块里的。

看几个例子:

scala> val a = try { 100 } finally { 200 }
<console>:7: warning: a pure expression does nothing in statement position; you may be omitting necessary parentheses
   val a = try { 100 } finally { 200 }
                                 ^
a: Int = 100

上面try-finally语句的结果是try里的值。

scala> val a = try { throw new Exception() } catch { case e:Exception => 200 }
a: Int = 200

上面try里发生了异常,最终的结果是catch里的。

scala> val a = try { throw new Exception() } catch { case e:Exception => 200 } finally { 300 }
<console>:7: warning: a pure expression does nothing in statement position; you may be omitting necessary parentheses
   val a = try { throw new Exception() } catch { case e:Exception => 200 } finally { 300 }
                                                                                     ^
a: Int = 200

上面finally里的表达式并不会被当作最终返回值。

当然,在finally块里是可以使用return关键字的,但return关键字在这里并不能改变try-finally表达式的结果类型,我们对原代码增加return再编译:

def foo():String = {
    try{
        Thread.sleep(1000)
    }finally{
      return "ok"
    }
}

 ➜  scalac A.scala
A.scala:4: error: type mismatch;
 found   : Unit
 required: String
      Thread.sleep(1000)
                  ^
one error found

依然编译错误,注意return是面向method的,属于流控层面,并不影响表达式的类型推断。因为在foo方法里try-finally就是最后一句表达式,所以编译器要求这句表达式的类型必须也满足foo的返回值类型签名。如果try-finally不是最后一句,就没有这个约束了,比如:

def foo():String = {
    try{
        Thread.sleep(1000)
    }finally{
      return "ok"
    }

    "no"
}

上面对foo方法在最后一行增加了一句返回”no”的表达式,使得前边的try-finally表达式类型推导不受方法签名的约束,编译可以通过了。当然这个代码逻辑肯定不会走到那里,我更希望编译器给出代码不可达的警告。

如果打开typer-debug编译选项,可以看到编译器总会期待方法里的最后一个表达式满足方法返回值类型,如果最后的这个表达式又是由多个更小粒度的表达式组合成的(比如这个try-finally,我们暂称它为大表达式),则进一步对这个大表达式拆分推导,约束其中的决定整个大表达式类型的小表达式也必须符合方法的返回类型,对于try-finally这个大表达式来说就是其中try块里的最后一行表达式。

对于try-catch-finallyfinally,编译器总是预期它里面的表达式类型为Unit,所以如果在里面的最后一条语句不是一个Unit类型的值,编译会自动给你加上。

注意,return ok这句表达式的类型是Nothing,不要混淆方法返回值类型和表达式自身类型。return, throw等跟流控相关的表达式都是Nothing,它可以满足任何类型,自发可以符合finally里的Unit预期。

其实这个问题是scala类型推导实现的问题,我们期望它更聪明一些,比如:

scala> def bar:String = { return "A";  "B"  }
bar: String

在Java里编译器会报错后边的语句不可达,但Scala里却编译通过。虽然后边的表达式没有意义,不会走到那儿,但并不意味着你能给出任意的值:

scala> def bar:String = { return "A";  200 }
<console>:7: error: type mismatch;
 found   : Int(200)
 required: String
   def bar:String = { return "A";  200 }

尽管后边的表达式不会被执行到,但它在编译时参与类型推导,因为对于该方法来说 { return "A"; 200 }整体是一个大表达式也必须满足String类型才行。

scala雾中风景(17): toSet()的谜题

看到hackernews上推荐的《scala: the good,the bad and the very ugly》在这篇ppt里,有一个例子挺有意思的:

List(1,2,3).toSet()

猜一下它的结果,直观上我们会认为它返回一个Set类型的集合,实际却不是:

scala> List(1,2,3).toSet()
<console>:8: warning: Adaptation of argument list by inserting () has been deprecated: this is unlikely to be what you want.
    signature: GenSetLike.apply(elem: A): Boolean
  given arguments: <none>
 after adaptation: GenSetLike((): Unit)
          List(1,2,3).toSet()
                           ^
res1: Boolean = false

在repl下测试,在给出警告之后,输出了一个Boolean值。让人大跌眼镜,要想得到预期的结果,方法后边的小括号是不能写的:

scala> List(1,2,3).toSet
res9: scala.collection.immutable.Set[Int] = Set(1, 2, 3)

其实警告信息里已经很明确的说明了,toSet()实际是对toSet的结果再调用的GenSetLike.apply(elem: A),即它相当于

List(1,2,3).toSet.apply()

如果apply方法是无参的,上面的也好理解,但警告信息里提示的是:GenSetLike.apply(elem: A) 明明时带有一个参数的,为什么用空的参数apply()也可以运行?

等等,警告信息里还有2句:

given arguments: <none>
after adaptation: GenSetLike((): Unit)

这两句是说给了一个空参数,被编译器适配成了一个Unit类型的()实例对象,是不是有些匪夷所思?编译器为何要自作聪明?如果我们对Unit类型的谜题还有印象的话,会怀疑是否因为Unit类型用作方法参数引起的:

scala> def foo(u:Unit) { println("ok") }

scala> foo() // 可以运行,同样也会有类似上面的警告信息

参考以前两篇有关Unit的: scala雾中风景(4): Unit类型scala雾中风景(8): 高阶函数与Unit的谜题

不过,这里是否真的就是Unit类型引起的? 虽然警告信息里提示把空参数适配成了Unit类型,但并不是apply方法声明的参数,GenSetLike.apply(elem: A) 参数类型是一个泛型参数,我们先根据直觉判断 List(1,2,3).toSet 之后得到的是Set[Int],所以这里A是Int类型?我们一步步来验证一下:

scala> val s = List(1,2,3).toSet
s: scala.collection.immutable.Set[Int] = Set(1, 2, 3)

scala> s.apply() // 错误,提示缺乏参数!用IDE的话发现编译时就提示错误了

诡异了,分成两步的时候居然又不行了,虽然这时的提示更符合预期,但之前用一句表达 List(1,2,3).toSet.apply() 为什么又可以运行?

经过google之后,发现这竟然是一个很有趣的类型推导问题,参考这篇讨论

scala.collection.immutable.Set是非协变的(实际是invariant的),这点不同于List,Seq,Queue等主流集合的声明。为什么要定义为非协变的,也是由Set自身的函数特征决定的trait Set[A] extends (A => Boolean),可以参考这里的解释

TraversableOnce.toSet方法声明是:

def toSet[B >: A]: immutable.Set[B] = ...

注意,它返回的类型不同于List[A]里的A,而是A的某个父类型B!这里存在一个很常见的类型推导问题:

scala> List(1,2,3).toSet.map(x=>x+1) // 错误,缺乏参数类型

scala> List(1,2,3).toSet.map((x:Int)=>x+1) // 需要显式声明参数类型 
res28: scala.collection.immutable.Set[Int] = Set(2, 3, 4)

scala> List(1,2,3).toSet[Int].map(x=>x+1) // 或在toSet的时候显式的声明元素类型为Int 
res29: scala.collection.immutable.Set[Int] = Set(2, 3, 4)

或分两步,先得到set结果,再map不需要显式声明参数类型:

scala> val s = List(1,2,3).toSet // 先得到set结果
s: scala.collection.immutable.Set[Int] = Set(1, 2, 3)

scala> s.map(x=>x+1)  // OK
res30: scala.collection.immutable.Set[Int] = Set(2, 3, 4)

为啥分开写,跟连着写在toSet阶段的类型推导是不一样的呢,typesafe的开发Adriaan Moors给了解释

You can think of inference as a breadth-first sweep over the expression, collecting constraints (which arise from subtype bounds and required implicit arguments) on type variables, followed by solving those constraints. This approach allows, e.g., implicits to guide type inference. In your example, even though there is a single solution if you only look at the xs.toSet subexpression, later chained calls could introduce constraints that make the system unsatisfiable. The downside of leaving the type variables unsolved is that type inference for closures requires the target type to be known, and will thus fail (it needs something concrete to go on — the required type of the closure and the type of its argument types must not both be unknown).

Now, when delaying solving the constraints makes inference fail, we could backtrack, solve all the type variables, and retry, but this is tricky to implement (and probably quite inefficient).

大致是类型推导是广度优先的方式扫描表达式,搜集类型变量上由子类型界定和隐式参数导致的约束,接下来解释这些约束。。。

所以在一整句连着写List(1,2,3).toSet.apply()toSet阶段因为其上下文约束不同于单句的List(1,2,3).toSet,还未能推导此时Set的元素类型。

我们通过-Ytyper-debug选项可以看到分开写的时候,类型推导的确是明确推导出结果是Set[Int]了:

而连在一起写的时候,并未明确推导出Set的元素类型,这里B类型是Int的父类型:

现在,我们了解到了List(1,2,3).toSeq.apply()在apply()之前因为元素类型还未明确(B是Int的父类型),B可能是一个非常“泛”的类型,比如AnyVal或Any,这样相当于:

scala> val s: Set[Any] = List(1,2,3).toSeq
scala> s.apply()  // 给出警告,但运行OK!

现在问题就变成了,为和一个很宽泛的类型比如AnyAnyRef,用作参数类型的时候,实际调用这个方法时参数可以随意传递?

scala> def foo(a:Any) { println("ok") }
scala> foo(1,2,3) // 给出警告,但运行OK
scala> foo() // 给出警告,但运行OK

其实这个问题跟之前的这篇 scala雾中风景(16): println(1,2,3)为什么work? 属于同一个问题,scala编译器发觉对只有一个参数的方法在调用时参数不一致的情况下,会在最后阶段尝试一次“适配”,简单的说就是用”()”进行tuple化,如果参数多于一个,将整个TupleN当作参数传入,如果参数为0,则tupling得到一个Unit类型实例传入。

对于scala编译器在方法参数上自作聪明的“适配”,应该严格禁止它发生的可能,建议所有的项目编译时,都开启 -Yno-adapted-args 让编译器给出错误,避免混乱。

scala类型系统:类型推导

类型推导是一门博大的学问,背后有繁冗的理论,好在非编译器开发者不用太多了解,我看到这方面的文章时立刻就知难而退了。这里蜻蜓点水的带过。

Scala采用的局部的(local)、基于流的(flow-based)类型推断;而非像ML,Haskell等语言采用的更加全局化的Hindley-Milner类型推断方式。

在《Programming in Scala》一书中提到基于流的类型推断有它的局限性,但是对于面向对象的分支类型处理比Hindley-Mlner更加优雅。

基于流的类型推导在偏应用函数场景下,不能对参数类型省略,正体现了它的局限性。下面的偏应用函数声明,在Hindley-Milner类型推导(基于全局的)可以正常的推导的,会在scala里报错:

scala> def foo(a:Int, b:String) = a+b

scala> val f = foo(200, _)
<console>:8: error: missing parameter type for expanded function ((x$1) => foo(200, x$1))
   val f = foo(200, _)
                    ^

上面第二个参数占位符缺乏类型。需要显式的声明变量f的类型

scala> val f: String=>String = foo(200, _)
f: String => String = <function1>

或者显式的声明占位符参数类型

scala> val f = foo(200, _:String)
f: String => String = <function1>

不过这里有一个细节是eta-expansion的情况下可以省略参数类型,比如:

scala> def foo(a:Int, b:String) = a+b

scala> val f = foo _
f: (Int, String) => String = <function2>

scala> val f = foo(_,_)
f: (Int, String) => String = <function2>

上面两种写法占位符都是对全部参数,foo _foo(_,_) 在编译过程会编译器会进行eta-expansioneta扩展会把这个表达式转换为与foo方法类型一致的函数对象。这是在规范里定义的。

而最开始的写法 foo(200, _)并不是eta-expansion(原因见之前的这篇)。这种情况下类型推导并不会从方法体的上下文去推断参数的类型。所以需要显式的声明参数类型。

另外,对于泛型方法的情况,类型推导也需要注意泛型参数,参考这篇

泛型方法转换为部分应用函数时的类型推导问题

在泛型方法通过下划线方式转换为部分应用函数(偏应用函数)的时候,类型推导有一个细节。我们先看返回值是泛型(参数化)的情况:

泛型参数T会被编译器约束为 T >: Nothing <: Any,即上限是Any,下限是Nothing.

scala> def method[T]():T = null.asInstanceOf[T]
method: [T]()T

scala> val f = method _
f: () => Nothing = <function0>

如上,在只有结果类型是参数化类型的情况下,因为Function0的返回值类型声明为协变的,从里氏替换的角度,编译器进行推导时T会选用最底层的类型,这个类型是Nothing

scala> def method[T](p:T) = println("ok")
method: [T](p: T)Unit

scala> val f = method _
f: Any => Unit = <function1>

如上,在只有方法参数是参数化类型的情况下,因为Function1的参数类型声明是逆变的,从里氏替换的角度,编译器进行推导时T会选用最上层的类型,这个类型是Any

而若方法的参数和返回值都用参数化类型表示时,则类型推导选择的是都是Nothing,而非入参是 Any,返回值是Nothing 这种不一致的行为。

scala> def method[T](p:T) = p
method: [T](p: T)T

scala> val f = method _
f: Nothing => Nothing = <function1>

归根到底,是因为scala/java的方法支持泛型(具备类型参数化多态),而scala的函数却在构造时需要具体的输入和输出类型,所以在从方法转换为函数的过程时必然是有所损失的。