hangscer

Warts of the Scala Programming Language(中文翻译)

2017/05/30

翻译自lihaoyi的文章(访问需梯子)
原创翻译,转载请联系译者

    
    Scala是我目前最喜欢的多用途的编程语言。然而它是有些缺陷的。语言中有些设计是经过仔细权衡,有些则是试验性的,那些愚蠢的问题所带来挫败感远超过他们的成功之处:warts(作者把语言中的失败之处比喻为疣子,中文把它比作糟粕更好)。这篇文章讲阐述我所认为的Scala语言的糟粕,希望可以提高人们对这些问题的认识,也希望可以集合更广大的社区力量来修复它。

About the Author: Haoyi is a software engineer, an early contributor to Scala.js, and the author of many open-source Scala tools such as the Ammonite REPL and FastParse.
If you’ve enjoyed this blog, or enjoyed using Haoyi’s other open source libraries, please chip in (or get your Company to chip in!) via Patreon so he can continue his open-source work

    许多语言有着相对浅显的隐藏了疯狂和不可言语的内部核心逻辑的语言表皮层(原作为relatively clean superficial syntax and semantics)。在我的观点中,Scala用粗糙散乱的语言表皮层覆盖了相对优雅的核心逻辑。

    并不是编程语言的每个问题都是糟粕。许多问题是由深度设计和权衡利弊而产生的,常常并没有所谓最正确的设计,或者提出的解决方案带来了其它问题。
    总之, 有些问题仅仅是偶然发生的。它们毫无存在的理由,也有着毫无异议的解决方案。它们真应该在数年前就被修复,虽然今天修复它们还不算迟。

Not Wart:

    有时人们抱怨的东西并不一定是"wart"

Universal Equality

    通用的相等操作

1
2
3
4
5
6
7
8
scala> val x="String"
x: String = String
scala> val y=123
y: Int = 123
scala> x==y
res0: Boolean = false

    Scala允许通过==来比较两个值是否相等。==是进一步调用Java中的equals方法。equals签名如下:

1
def equals(obj: Any): Boolean

    这样就在编译时期允许各式各样的人为错误而不去捕获:以上的这个例子将不会返回true,无论xy的值,编译器应该弄清楚并告诉我们。上面的这个例子太短而且错误太明显。下面的这个例子相对不太明显:

1
2
scala> "foobar".toList == List('f','o','o','b','a,'r')
res3: Boolean = false

    'a''a的不同大家能够区别吧!
    尽管事实上允许通过这么明显的错误,但是对我还是不够清晰的去寻找一个正确的方法去解决它:

  • 大部分语言遵循这样的解决措施:Java、C#等等允许你不加区分地比较两个值,即使它们的类型根本不等。
  • Haskell使用Eq类型,这似乎是我们想要的:对类型的限制比较有意义。
  • Scala研发者想出了相似但不同的提议–Multiversal Equality

    这种不安全的比较使错误变得非常频繁。在重构代码时,把String类改成Id类时,需要修正所有的编译器错误,但是,所有的相等性检查现在都是无效的。没了编译器的帮助,你必须手动去捕捉它们。

Running on the JVM

    Scala一般是运行在JVM上。现在它也可以借助Scala.js编译到Javascript语言,通过Scala Native将Scala编译成本地代码。但是生态系统以及全部的工具全部运行在JVM上。
    这里列举运行在JVM的一些优点:优秀的运行栈追踪、监控/部署工具、快速的JIT编译器、优秀的GC、大量的生态库。
    这里也列举JVM带来的约束:无处不在的装箱给GC带来压力、类文件以及jar包的体积较大、较慢的启动速度。
    举个例子,Ammonite script-runner(一个命令行工具)运行脚本时,在计算任何实际程序逻辑之前就需要花费半秒来装载类(classLoading)。


    不管怎么样,对JVM的信赖足以成就Scala语言的核心特征。如果当初Scala没有选择基于JVM,那么Scala是否能够取得今天的成功还是值得商榷的。

Type-erasure

1
2
3
4
5
6
7
8
9
scala> val x=List(1,2,3)
x: List[Int] = List(1, 2, 3)
scala> val y=x.asInstanceOf[List[String]]
y: List[String] = List(1, 2, 3)
scala> y.head
java.lang.ClassCastException: java.lang.Integer cannot be cast to java.lang.String
... 42 elided

    无论运行在JVM上,或者通过Scala.js编译到Javascript,泛型都是被擦除的,意味着你可以对它们使用asInstanceOfisInstanceOf或者模式匹配。你可以把List[Int]强转为List[String],仅当你试图从强转的列表中提取值它才会出错。
    这种行为明显不够安全,而且会导致奇怪难以追踪的问题。不管怎么样,我还不清楚究竟何种做法才是正确的措施:

  • 你可以选择不擦除泛型,就像C#。在编译到JVM平台和javascript时,常常伴随着性能损失,而且与已存在的擦除泛型JVM类库交互时会变得不太方便。
  • 你也可以选择更加激进的泛型擦除,像Scala.js,甚至在运行时把最高阶层的部分类型擦除了,着就以为着下面的这段代码将不会报任何错误。
1
2
3
4
// Scala.js
val x = List(1, 2, 3)
val y = x.asInstanceOf[List[String]]
println(y.last) // 3

    Scalajs的”全”擦除(Full erasure)有着若干有趣的特点,比如幻影类型(原文为phantom-type)以及零开销的包装类型,这些对于JVM都是难以实现的。这些特征允许更加激进的优化,以至于如果你执行了错误的类型强转操作,你可能也不会得到ClassCastException错误信息。依我经验,典型的Scala编程风格不应该过度依赖于类型强转。

implicits

    Scala允许使用隐式参数,隐式参数根据它们的类型可以自动被传入调用函数中。举个例子,接下来的案例中,我们将调用repeat两次,一次将传入参数count为2,另一次将传入隐式参数implicit val c

1
2
3
4
5
6
7
8
9
10
11
scala> def repeat(str:String)(implicit count:Int)=str*count
repeat: (str: String)(implicit count: Int)String
scala> repeat("hello")(2)
res0: String = hellohello
scala> implicit val c=3
c: Int = 3
scala> repeat("jianghang")
res1: String = jianghangjianghangjianghang

Scala也允许隐式转换(implicit conversions),如下:

1
2
3
4
5
6
7
8
9
10
11
12
scala> case class Name(s:String)
defined class Name
scala> val n1=Name("jianghang")
n1: Name = Name(jianghang)
scala> implicit def autoName(s:String):Name=Name(s)
warning: there was one feature warning; re-run with -feature for details
autoName: (s: String)Name
scala> val n2:Name="jianghang2"
n2: Name = Name(jianghang2)

    隐式转换随之带来的不够直观与硬编码确实令人困惑。Scala设计模式大量涉及到隐式转换,如果没有隐式转换,那么Scala将不再是Scala。

Warts

Weak eta-expansion

    Scala中严格区分函数与方法的概念。一般的,对象可以调用方法;函数是一种对象。总之,它们是如此的相似。你可以把方法包裹成函数对象的行为称为eta expansion。如下:

1
2
3
4
5
6
7
8
9
10
11
scala> def repeat(s:String,i:Int):String=s*i
repeat: (s: String, i: Int)String
scala> repeat("jianghang",2)
res2: String = jianghangjianghang
scala> val func=repeat _
func: (String, Int) => String = <function2>
scala> func("hang",3)
res3: String = hanghanghang

    以上的代码中,我们使用下划线_来把repeat _运算结果赋值于funcfunc我们可以作为函数对象调用。即使不使用_,我们可以使用期望类型(expected type)来达到这一效果。举个例子,我们可以声明func的类型为(String,Int)=>String:

1
2
scala> val func:(String,Int)=>String = repeat
func: (String, Int) => String = <function2>

    或者用_单独替换参数:

1
2
scala> val func=repeat(_,_)
func: (String, Int) => String = <function2>

    这样确实是可以运行的,但是也带来了恼人的限制,你可以使用_来全部替换repeat的参数,但是你不能部分替换它:

1
2
3
4
scala> repeat("asdsa",_)
<console>:17: error: missing parameter type for expanded function ((x$1) => repeat("asdsa", x$1))
repeat("asdsa",_)
^

    除非你声明了期望类型,这样你才可以局部替换它:

1
2
scala> val func:Int=>String=repeat("sadas",_)
func: Int => String = <function1>

    或者你可以为被_替换的参数提供类型:

1
2
scala> val func=repeat("sadas",_:Int)
func: Int => String = <function1>

    这着实令人奇怪,如果我可以不用说明任何类型而把repeat整个方法转化为函数对象,那么为什么在我已经知道参数类型情况下不能部分转换方法呢?
而且,还有一个更普遍的问题:如果我已经知道了repeat方法需要传入两个参数,那么为什么我不能像这样做:

1
2
3
4
5
6
scala> val func=repeat
<console>:16: error: missing argument list for method repeat
Unapplied methods are only converted to functions when a function type is expected.
You can make this conversion explicit by writing `repeat _` or `repeat(_,_)` instead of `repeat`.
val func=repeat
^

    编译器已经知道repeat是一个方法,而且我也没有给它提供任何参数,为什么不能自动转换呢?为什么强制通过_或者(_,_),又或者为什么在已经知道repeat方法类型时还需要我提供期望类型呢?
    在其它一等函数的语言,比如python中:

1
2
3
4
5
6
7
>>> def repeat(s, i):
... return s * i
>>> func = repeat
>>> func("hello", 3)
'hellohellohello'

Callers of zero-parameter methods can decide how many parens to use

    当参数个数为零时,Scala允许不用写一对括号,这样的写法有点像在调用getters:

1
2
3
4
5
6
7
8
scala> def getFoo()=1234
getFoo: ()Int
scala> getFoo()
res0: Int = 1234
scala> getFoo
res1: Int = 1234

    然而,依其它语言经验来看,以上的这段代码可能难以理解,比如python:

1
2
3
4
5
6
7
>>> def getFoo():
... return 1337
>>> getFoo()
1337
>>> func = getFoo
>>> func()
1337

    如果getFoo()返回Int,那么去括号的getFoo为什么不应该是()=>Int呢?毕竟,写明括号去调用()=>Int是返回Int的。However,正如以上所见,在Scala中方法有些特别,那些不带括号的方法调用会被特别对待。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
scala> def bar()()()()()=2
bar: ()()()()()Int
scala> bar
res3: Int = 2
scala> bar()
res4: Int = 2
scala> bar()()
res5: Int = 2
scala> bar()()()
res6: Int = 2
scala> bar()()()()
res7: Int = 2
scala> bar()()()()()
res8: Int = 2
scala> bar()()()()()()
<console>:13: error: Int does not take parameters

bar的类型究竟(on earth)是什么,这真的是我们对于静态语言所期待的行为吗?
    我的解决方法很简单:方法在定义时使用了几对括号,那么在调用该方法时,一对括号都不少。任何缺少括号的方法都应该经过eta-expansion转换为函数对象。
    不带括号的函数也是可以定义的,如下:

1
2
3
4
5
6
7
8
scala> def baz=3
baz: Int
scala> baz
res10: Int = 3
scala> baz()
<console>:13: error: Int does not take parameters

    ”当你调用java中的getFoo方法时可以省略括号”,这似乎是使用这项功能的唯一理由。

Needing curlies/case for destructuring anonymous functions

    Scala允许你使用诸如x=>x+1这样的匿名函数。

1
2
scala> Seq(1,2,3,4).map(x=>x+1)
res12: Seq[Int] = List(2, 3, 4, 5)

    但是当你对容器元素为元组时,使用匿名函数则无效:

1
2
3
4
5
6
7
8
scala> Seq((1->2),(3->4)).map((x,y)=>x+y+1)
<console>:12: error: missing parameter type
Note: The expected type requires a one-argument function accepting a 2-Tuple.
Consider a pattern matching anonymous function, `{ case (x, y) => ... }`
Seq((1->2),(3->4)).map((x,y)=>x+y+1)
^
<console>:12: error: missing parameter type
Seq((1->2),(3->4)).map((x,y)=>x+y+1)

    此时你需要使用相似但又不同的语句{case ...=>...}(你可以把它理解为偏函数)。

1
2
scala> Seq((1->2),(3->4)).map{ case (x,y)=>x+y+1}
res14: Seq[Int] = List(4, 8)

    这里有两点需要说明:

  • 需要使用case来解构元组。为什么编译器不能将(A,B)=>C自动转换为Tuple[A,B]=>C呢?
  • 需要花括号,而不是圆括号。使用case关键字,那就是偏函数了,所以应当使用花括号,比如:Seq(1,2,3).map(case x=>x+1)是会报错的。

值得欣慰的是,这些限制在未来的版本中将被移除。

Extraneous extension methods on Any

    在Scala中,每个值都有着一堆来自Any的拓展方法:

1
2
3
4
5
scala> 1 formatted("hello %s")
res17: String = hello 1
scala> 10 ensuring(i=>i>3)
res21: Int = 10

    在我的编程经验中,这些拓展方法几乎很少使用到。如果有人需要使用它们,那么他完全可以编写自己的代码库来拓展功能。实在不应该浪费了大量命名空间来增加从来没过的函数。

Convoluted de-sugaring of for-comprehensions

    Scala允许你使用for推导式,for推导式flatMapmap等函数的语法糖,for推导式最终也会转换为flatMapmap等函数的链式调用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@ val (x, y, z) = (Some(1), Some(2), Some(3))
x: Some[Int] = Some(1)
y: Some[Int] = Some(2)
z: Some[Int] = Some(3)
@ for{
i <- x
j <- y
k <- z
} yield i + j + k
res40: Option[Int] = Some(6)
@ desugar{
for{
i <- x
j <- y
k <- z
} yield i + j + k
}
res41: Desugared = x.flatMap{ i =>
y.flatMap{ j =>
z.map{ k =>
i + j + k
}
}
}

    我已经将”去糖”后的代码排版好展示给你,你还可以在Ammonite Scala REPL中亲自验证for推导式的转换。
    利用for推导式可以很方便地对列表进行嵌套循环,其列表元素可以为OptionFuture等等。
    你也可以在for推导式中指定变量等等:

1
2
3
4
5
6
7
@ for{
i <- x
j <- y
foo = 5
k <- z
} yield i + j + k + foo
res42: Option[Int] = Some(11)

    这样的用法似乎用处不大(你不能在for推导式中使用val关键字,也不能使用def定义方法,更不能使用class定义类,只能使用诸如_ = println("debug")这样的用法才能在推导式内执行命令语句)。

1
2
3
4
for{
i<-Some(1)
_ = println("asd")
}yield i

    但是你可以轻易在推导式内分配变量,你可能猜想推导式的”去糖”后的代码如下所示😷😷😷😷:

1
2
3
4
5
6
7
8
res43: Desugared = x.flatMap{ i =>
y.flatMap{ j =>
val foo = 5
z.map{ k =>
i + j + k
}
}
}

    但是实际上代码长这样😂😂😂😂:

1
2
3
4
5
6
7
8
9
10
res43: Desugared = x.flatMap(i =>
y.map{ j =>
val foo = 5
scala.Tuple2(j, foo)
}.flatMap((x$1: (Int, Int)) =>
(x$1: @scala.unchecked) match {
case Tuple2(j, foo) => z.map(k => i + j + k + foo)
}
)
)

尽管最终结果相同,但是中间代码是如此复杂与低效。

For-comprehensions syntax restrictions

    上文提到,你不能在推导式中使用def或者class关键字,命令语句也不能使用:

1
2
3
4
5
6
scala> for{
| i<-Seq(1)
| println("hello ,jianghang")
| }yield i
<console>:4: error: '<-' expected but '}' found.
}yield i

    这真是个毫无理由随意的约束(语法噪音),没有任何原因,你只能在语句前加上_=作为前缀,才能使之编译成功:

1
2
3
4
5
6
7
8
scala> for{
| i<-Seq(1,2)
| _ = println("haha")
| j<-Seq(3,4)
| }yield (i,j)
haha//请注意这里为什么会被打印两次
haha
res0: Seq[(Int, Int)] = List((1,3), (1,4), (2,3), (2,4))

    令人遗憾的是,对于以下的代码并不可以运行:

1
2
3
4
5
6
7
8
for{
i <- Seq(1)
def debug(s: Any) = println("Debug " + s)
debug(i)
j <- Seq(2)
debug(j)
k <- Seq(3)
} yield i + j + k

    应当转换为(转换后可运行):

1
2
3
4
5
6
7
8
9
10
Seq(1).flatMap{ i =>
def debug(s: Any) = println("Debug " + s)
debug(i)
Seq(2).flatMap{ j =>
debug(j)
Seq(3).map{ k =>
i + j + k
}
}
}

Abstract/non-final case classes

    Scala中允许继承case class样式类,还可以拓展新的功能。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
scala> case class Foo(i:Int)
defined class Foo
scala> Foo(1)
res1: Foo = Foo(1)
scala> Foo(1).i
res2: Int = 1
scala> class Bar extends Foo(2)
defined class Bar
scala> (new Bar).i
res3: Int = 2
scala> abstract case class Id(i:Int)
defined class Id
scala> class MyId(i:Int) extends Id(i)
defined class MyId

    你甚至还可以声明抽象样式类来迫使继承它而不是实例化。如果你像样式类不可被继承,则需要使用final关键字修饰。我所见过的能够把抽象样式类以及它们之间的继承玩的转的程序员大概只有马丁·奥德斯基本人了。如果人们想要实现继承,那么请使用一般的类,而不是case class样式类。

Classes cannot have only implicit parameter lists

    以下的代码是不能运行的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
scala> class Foo(i:Int)
defined class Foo
scala> new Foo(1)
res0: Foo = Foo@1aec7350
scala> class Bar(implicit i: Int)
defined class Bar
scala> new Bar(1)
<console>:13: error: too many arguments for constructor Bar: ()(implicit i: Int)Bar
scala> implicit val i: Int=10
i: Int = 10
scala> new Bar
res3: Bar = Bar@6e05534f
scala> class Id(x:Int)(y:Int)
defined class Id
scala> new Id(1)(2)
res2: Id = Id@1f67922d

    但是,这样却是可以运行的😰😰😰😰:

1
2
scala> new Bar()(1)
res4: Bar = Bar@7e40e51d

(恐怖如斯)这何止是”Wart”,简直就是”Bug”。

Presence of comments affects program logic

    你知道注释也能够影响程序逻辑吗(依旧恐怖如斯😱)?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
object Foo{
def bar(x:Any)=println("haha x:"+x)
def bar=println("haha")
}
{
Foo bar
1
}
res:haha x:1
{
Foo bar
1
}
res:haha
{
Foo bar
// Wooo!
1
}
res:haha x:1
{
Foo bar
// Wooo!
1
}
res:haha
{
Foo bar
// Wooo!
// Wooo!
1
}
res:haha x:1

    正如你所见,当Foo bar1之间有若干空行时,代码将会得到不同结果,除非用注释填充那些空行。当没有空行或者空行被注释填充,Foo.bar(x:Any)将被调用;当有空行时且空行没有注释填充时,Foo.bar将被调用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
scala> {
| class X(x:Int)(y:Int)
|
| new X(1)(2)
|
| class Y(x:Int)
| (y:Int)
|
| new Y(1)(2)
| }
res0: AnyRef = Y$1@1cf941b2
scala> {
| class Z(x:Int)
|
| (y:Int)
| }
<console>:15: error: not found: value y
scala> {
| class W(x:Int)
| //Woooo!
| (y:Int)
| }