在本章的剩余篇幅和之后的两章中,我们将对Scala的一些特性进行快速讲解。在学习这些内容时会涉及一些语言细节,这些细节仅用于理解这些内容,更多的细节会在后续章节中提供。你可以将这几章内容视为Scala语法入门书,并从中感受Scala编程的魅力。
当提到某一Scala库类型时,我们可以阅读Scaladoc中的相关信息进行学习。如果你想访问当前版本的Scala对应的Scaladoc文档,请查看http://www.scala-lang.org/api/current/。请注意,左侧类型列表区域的上方有一搜索栏,应用该搜索栏能很方便地快速查找类型。与Javadoc不同,Scaladoc按照package来排列类型,而不是按照字母顺序全部列出。
本书多数情况下会使用Scala REPL,因此我们在这儿再温习一遍运行REPL的三种方式。你可以不指定脚本或main参数直接输入scala命令,也可以使用SBT console命令,还可以在那些流行的IDE中使用worksheet特性。
假如你不想使用任何IDE,我建议你尽量使用SBT,尤其是当你的工作固定在某一特定项目时。本书也将使用SBT进行讲解,这些操作步骤同样适用于直接运行scala命令或者在IDE中创建worksheet的情况。请自行选择开发工具。事实上,即便你青睐于使用IDE,我还是希望你能尝试在命令行窗口运行一下SBT,了解SBT环境。我个人很少使用IDE,不过是否选择IDE只是个人的偏好罢了。
打开shell窗口,切换到代码示例所在的根文件夹并运行sbt。在>提示符后输入console。从现在开始,本书将省略关于sbt和scala输出的一些“程序化”的语句。
在scala>提示符中输入下列两行:
第一行代码中的val关键字用于声明不变变量book。可变数据是错误之源,因此我推荐使用不变值。
请注意,解释器返回值列出了book变量的类型和数值。Scala从字面量'Programming Scala'中推导出book属于java.lang.String类型(http://docs.oracle.com/javase/8/docs/api/java/lang/String.html)。
显示类型信息或在声明中显式指明类型信息时,这些类型标注紧随冒号,出现在相关项之后。为什么Scala不遵循Java的习惯呢?Scala常常能推导出类型信息,因此,我们在代码中总是看不到显式的类型标注。如果代码中省略了冒号和类型标注信息,那么与Java的类型习惯相比,item:type这一模式更有助于编译器正确地分析代码。
一般来说,当Scala语法与Java语法存在差异时,通常都会有一个充分的理由。比如说,Scala支持了一个新的特性,而这个特性很难使用Java的语法表达出来。
REPL中显示了类型信息,这有助于学习Scala是如何为特定表达式推导类型的。透过这个例子,可以了解到REPL提供了哪些功能。
仅使用REPL来编辑或提交大型的示例代码会比较枯燥,而使用文本编辑器或IDE来编写Scala脚本则会方便得多。编写完成之后,你可以执行脚本,也可以复制粘贴大段代码再执行。
我们再回顾一下之前编写的upper1.sc文件。
本书的下载示例压缩包中的每个示例的第一行均为注释,该注释列出了示例文件在压缩包中的路径。Scala遵循Java、C#、C等语言的注释规则,// comment 只能作用到本行行尾,而/* comment */则可以跨行。
我们再回顾一下前言中的内容。依照命名规范,脚本文件的扩展名为.sc,而编译后的文件的扩展名为.scala,这一命名规范仅适用于本书。通常,脚本文件往往也使用.scala扩展名。不过如果使用SBT构建项目,SBT会尝试编译这些以scala命名的文件,而这些脚本文件却无法编译(我们稍后便会讲到这些)。
我们首先运行该脚本,具体代码细节稍后讨论。启动sbt并执行console命令以开启Scala环境。然后使用:load命令加载(编译并运行)文件:
上述脚本中,只有最后一行才是println命令的输出,其他行则是REPL提供的一些反馈信息。
那么这些脚本为什么无法编译呢?脚本设计的初衷是为了简化代码,无须将声明(变量和函数)封装在对象中便是一种简化。而将Java和Scala代码编译后,声明必须封装在对象中(这是JVM字节码的需求)。scala命令通过一个聪明的技巧解决了冲突:将脚本封装在一个你看不到的匿名对象中。
假如你的确希望能将脚本文件编译为JVM的字节码(一组.class文件),可以在scalac命令中传入-Xscript <object>参数,<object>表示你所选中的main类,它是生成的Java应用程序的入口点。
执行完毕后检查当前文件夹,你会发现一些命名方式有趣的.class文件。(提示:一些匿名函数也被转换成了对象!)我们稍后会再讨论这些名字,Upper1.class文件中包含了主程序,我们将使用javap和Scala对应工具scalap,对该文件实施逆向工程!
最后,我们将对代码本身进行讨论,代码如下:
Upper类中的upper方法将输入字符串转换成大写字符串,并返回一个包含这些字符串的Seq(Seq表示“序列”,http://www.scala-lang.org/api/current/index.xhtml#scala.collection.Seq)对象。最后两行代码创建了Upper对象的一个实例,并调用这一实例将字符串“Hello”和“World!”转换为大写字符串,并最终打印出产生的Seq对象。
在Scala中定义类时需要输入class关键字,整个类定义体包含在最外层的一对大括号中({…})。事实上,这个类定义体同样也是这个类的主构造函数。假如需要将参数传递给这个构造函数,就要在类名Upper之后输入参数列表。
下面这小段代码声明了一个方法:
定义方法时需要先输入def关键字,之后输入方法名称以及可选的参数列表。再输入可选的返回类型(有时候,Scala能够推导出返回类型),返回类型由冒号加类型表示。最后使用等于号(=)将方法签名和方法体分隔开。
实际上,圆括号中的参数列表代表了变长的String类型参数列表,修饰strings参数的String类型后面的*号指明了这一点。也就是说,你可以传递任意多的字符串(也可以传递空列表),而这些字符串由逗号分隔。在这个方法中,strings参数的类型实际上是WrapppedArray(http://www.scala-lang.org/api/current/index.xhtml#scala.collection.mutable.WrappedArray),该类型对Java数组进行了封装。
参数列表后列出了该方法的返回类型Seq[String],Seq(代表Sequence)是集合的一种抽象,你可以依照固定的顺序(不同于遍历Set和Map对象那样的随机顺序和未定义顺序,遍历那类容器无法保证遍历顺序)遍历这类结合抽象。实际上,该方法返回的类型是scala.collection.mutable.ArrayBuffer(http://www.scala-lang.org/api/current/#scala.collection.mutable.ArrayBuffer),不过绝大多数情况下,调用者无须了解这点。
值得一提的是,Seq是一个参数化类型,就好象Java中的泛型类型。Seq代表着“某类事物的序列”,上面代码中的Seq表示的是一个字符串序列。请注意,Scala使用方括号([…])表示参数类型,而Java使用角括号(<…>)。
Scala的标识符,如方法名和变量名,中允许出现尖括号,例如定义“小于”方法时,该方法常被命名为<,这在Scala语言中是允许的,而Java则不允许标识符中出现这样的字符。因此,为了避免出现歧义,Scala使用方括号而不是尖括号表示参数化类型,并且不允许在标识符中使用方括号。
upper方法的定义体出现在等号(=)之后。为什么使用等号呢?而不像Java那样,使用花括号表示方法体呢?
避免歧义是原因之一。当你在代码中省略分号时,Scala能够推断出来。在大多数时候,Scala能够推导出方法的返回类型。假如方法不接受任何参数,你还可以在方法定义中省略参数列表。
使用等号也强调了函数式编程的一个准则:值和函数是高度对齐的概念。正如我们所看到的那样,函数可以作为参数传递给其他函数,也能够返回函数,还能被赋给某一变量。这与对象的行为是一致的。
最后提一下,假如方法体仅包含一个表达式,那么Scala允许你省略花括号。所以说,使用等号能够避免可能的解析歧义。
函数方法体中对字符串集合调用了map方法(http://www.scala-lang.org/api/current/index.xhtml#scala.collection.TraversableLike),map方法的输入参数为 函数字面量 (function literal)。而这些函数字面量便是“匿名”函数。在其他语言中,它们也被称为Lambda、 闭包 (closure)、 块 (block)或 过程 (proc)。Java 8 最终也提供了真正的匿名方法Lambda。但Java 8 之前,你只能通过接口实现的方式实现匿名方法,我们通常会在接口中定义一个匿名的内部类,并在内部类中声明执行真正工作的方法。因此,即便是在Java 8 之前,你也能够实现匿名函数的功能:通过传入某些嵌套行为,将外部行为参数化。不过这些繁琐的语法着实损害并掩盖了匿名方法这门技术的优势。
在这个示例中,我们向map方法传递了下列函数字面量:
此函数字面量的参数表中只包含了一个字符串参数s。它的函数体位于箭头=>之后(UTF8 也允许使用=>)。该函数体调用了s的UpperCase()方法。此次调用的返回值会自动被这个函数字面量返回。在Scala中,函数或方法中把最后一条表达式的返回值作为自己的返回值。尽管Scala中存在return关键字,但只能在方法中使用,上面这样的匿名函数则不允许使用。事实上,方法中也很少用到这个关键字。
对于大多数的面向对象编程语言而言,方法指的是类或对象中定义的函数。当调用方法时,方法中的this引用会隐性地指向某一对象。当然,在大多数的OOP语言中,方法调用的语法通常是this.method_name(other_args)。本书中的“方法”也满足这一常用规范。我们提到的“函数”尽管不是方法,但在某些时候通常会将方法也归入函数。当前上下文能够认清它们的区别。
upper1.sc 中表达式 (s:String) => s.toUpperCase() 便是一个函数,它并不是方法。
我们对序列对象strings调用了map方法,该方法会把每个字符串依次传递给函数字面量,并将函数字面量返回的值组成一个新的集合。举个例子,假如在原先的列表中有五个元素,那么新生成的列表也将包含五个元素。
继续上面的示例,为了进一步练习代码,我们会创建一个新的Upper实例并将它赋给变量up。与Java、C#等类似语言一样,new Upper语法将创建一个新的实例。由于主构造函数并不接受任何参数,因此并不需要传递参数列表。通过val关键字,up参数被声明为只读值。up的行为与Java中的final变量相似。
最后,我们调用upper方法,并使用println(…)方法打印结果。
我们可以进一步简化代码,请思考下面更简洁的版本。
这段代码同样实现了相同的功能,但使用的字符却相对较少,简洁度排名第三。
在第一行中,Upper被声明为单例对象,Scala将单例模式视为本语言的第一等级成员。尽管我们声明了一个类,不过Scala运行时只会创建Upper的一个实例。也就是说,你无法通过new创建Upper对象。就好像Java使用静态类型一样,其他语言使用 类成员 (classlevel member),Scala则使用对象进行处理。由于Upper中并不包含状态信息,所以我们此处的确不需要多个实例,使用单例便能满足需求。
单例模式具有一些弊端,也因此常被指责。例如在那些需要将对象值进行double的单元测试中,如果使用了单例对象,便很难替换测试值。而且如果对一个实例执行所有的计算,会引发线程安全和性能的问题。不过正如静态方法或静态值有时适用于Java这样的语言一样,单例有时候在Scala中也是适用的。上述示例便是一个证明,由于无须维护状态而且对象也不需要与外界交互,单例模式适用于上述示例。因此,使用Upper对象时我们没有必要考虑测试双倍值的问题,也没有必要担心线程安全。
Scala为什么不支持静态类型呢?与那些允许静态成员(或类似结构)的语言相比,Scala更信奉万物皆应为对象。相较于混入了静态成员和实例成员的语言,采用对象结构的Scala更坚定地贯彻了这一方针。回想一下,Java的静态方法和静态域并未绑定到类型的实际实例中,而Scala的对象则是某一类型的单例。
第二行中upper的实现同样简洁。尽管Scala无法推断出方法的参数类型,却常常能够推断出方法的返回类型,因此我们在此省略返回类型的显式声明。同时,由于方法体中仅包含了一句表达式,我们可以省略括号,并在一行内完成整个方法的定义。除了能提示读者之外,方法体之前的等号也告诉编译器方法体的起始位置。
Scala为什么无法推导出方法参数类型呢?理论上类型推理算法执行了局部类型推导,这意味着该推导无法作用于整个程序全局,而只能局限在某一特定域内。因此,尽管无法分辨出参数所必须使用的类型,但由于能够查看整个函数体,Scala大多数情况下却能推导出方法的返回值类型。递归函数是个例外,由于它的执行域超越了函数体的范围,因此必须声明返回类型。
任何时候,参数列表中的返回类型都为读者提供了有用信息。仅仅是因为Scala能推导出函数的返回类型,我们就放弃为读者提供返回类型信息吗?对于简单的函数而言,读者能够很清楚地发现返回类型,显式列出的返回类型也许还不是特别重要。不过有时候由于bug或某些特定输入或函数体中的某些表达式所触发的某些微妙行为,推导出的类型可能并不是我们所期望的类型。显式返回类型代表了你所期望的返回类型,它们同时还为读者提供了有用信息,因此我推荐添加返回类型,而不要省略它们。这尤其适用于公有API。
我们对函数字面量进行了进一步的简化,之前我们的代码如下:
我们将其简化为下列表达式:
map方法接受单一函数参数,而单一函数也只接受单一参数。在这种情况下,函数体只使用一次该参数,所以我们使用占位符_来替代命名参数。也就是说:_起到了匿名参数的作用,在调用toUpperCase方法之前,_将被字符串替换。Scala同时也为我们推断出了该变量的类型为String类型。
最后一行代码中,由于使用了对象而不是类,此次调用变得更加简单。无须通过new Upper代码创建实例,我们只需直接调用Upper对象的upper方法。调用语法与调用Java类静态方法时的语法一样。
最后,Scala会自动加载一些像println(http://www.scala-lang.org/api/current/index.xhtml#scala.Console$)这样的I/O方法,println方法实际是scala包(http://www.scala-lang.org/api/current/ scala/package.html)中Console对象(http://www.scala-lang.org/api/current/index.xhtml#scala.Console$)的一个方法。与Java中的包一样,Scala通过包提供“命名空间”并界定作用域。
因此,使用println方法时,我们无需调用scala.Console.println方法(http://www.scala lang.org/api/current/index.xhtml#scala.Console$),直接输入println即可。println方法只是众多被自动加载的方法和类型中的一员,有一个叫作Predef的库对象(http://www.scalalang.org/api/current/index.xhtml#scala.Predef$)对这些自动加载的方法和类型进行定义。
我们再进行一次重构,把这个脚本转化成编译好的一个命令行工具。也就是说,我们将创建一个包含了main方法的更为经典的JVM应用程序。
回顾一下前面的内容,如果代码具有.scala扩展名,那就表示我们会使用scalac编译它。现在upper方法被改名成了main方法。由于Upper是一个对象,main方法就像是Java类的静态main方法一样。它就是Upper应用的入口点。
在Scala中,main方法必须为对象方法。(在Java中,main方法必须是类静态方法。)应用程序的命令行参数将作为一组字符串传递给main方法。举例来说,输入参数是args: Array[String]。
upper1.scala文件中的第一行代码定义了名为introscala的包,用于装载所定义的类型。在Upper.main方法中的表达式使用了map方法的简写形式,这与我们之前代码中出现的简写形式一致。
map方法会返回一个新的集合。对该集合我们将使用foreach方法进行遍历。我们向foreach方法中传递另一个使用了_占位符的函数字面量。在这段代码中,集合中的每一个字符串都将作为参数传递给scala.Console.printf方法(http://www.scala-lang.org/api/current/index.xhtml#scala.Console$),该方法也是Predef对象导入的方法,它会接受代表格式的字符串参数以及一组将嵌入到格式字符串的参数。
在此澄清一下,上述代码有两处使用了_,这两个_分别位于不同的作用域中,彼此之间没有任何关联。
你需要花一些时间才能掌握这样的链式函数以及函数字面量中的一些简写方式,不过一旦熟悉了它们,你便能应用它们编写出可读性强、简洁强大的代码,这些代码能最大程度地避免使用临时变量和其他一些样板代码。如果你是一名Java程序员,可以想象一下使用早于Java 8 的Java版本编写代码,这时你需要使用匿名内部类才能实现相同的功能。
main方法的最后一行在输出中增加了一个最终换行符。
为了运行代码,你必须首先使用scalac,将代码编译成一个能在JVM下运行的.class文件(下文中的$代表命令提示符)。
现在,你应该会看到一个名为progscala2/introscala的新文件夹,该文件夹里包含了一些.class文件,Upper.class便是其中的一个文件。Scala生成的代码必须满足JVM字节代码的合法性要求,文件夹目录必须与包结构吻合是要求之一。
Java在源代码级也遵循这一规定,Scala则要更灵活一些。请注意,在我们下载的代码示例中,文件Upper.class位于一个叫作IntroScala的文件夹中,这与它的包名并不一致。Java同时要求必须为每一个最顶层类创建一个单独的文件,而Scala则允许在文件中创建任意多个类型。虽然开发Scala代码可以不用遵循Java关于源代码目录结构的规范(源代码目录结构应吻合包结构,而且为每个顶层类创建一个单独的文件),不过一些开发团队依然遵循这些规范,这主要因为他们熟悉这些Java规范,而且遵循这些规范有利于追踪代码位置。
现在,你可以输入任意长度的字符串参数并执行命令,如下所示:
我们通过选项-cp .将当前目录添加到查询类路径(classpath)中,不过本示例其实并不需要该选项。
请尝试使用其他输入参数来执行程序。另外,你可以查看progscala2/introscala文件夹中还有哪些其他的类文件,像之前例子那样使用javap或scalap命令查看这些类中包含了什么定义。
最后,由于SBT会帮助我们编译文件,我们实际上并不需要手动编译这些文件。在SBT提示符下,我们可以使用下列命令运行程序。
使用scala命令运行程序时,我们需要指明SBT生成的类文件的正确路径。
概括地说,假如在命令行输入scala命令时不指定文件参数,REPL将启动。在REPL中输入的命令、表达式和语句都会被直接执行。假如输入scala命令时指定Scala源文件,scala命令将会以脚本的形式编译并运行文件。另外,假如你提供了JAR文件或是一个定义了main方法的类文件,scala会像Java命令那样执行该文件。
我们接下来对这些代码再进行最后一次重构:
将输入参数映射为大写格式字符串之后,我们并没有使用foreach方法迭代并依次打印每个词,而是通过一个更便利的集合方法生成字符串。mkString方法(http://www.scala-lang.org/api/current/index.xhtml#scala.collection.TraversableOnce)只接受一个输入参数,该参数指定了集合元素间的分隔符。另外一个mkString方法(重构版本)则接受三个参数,分别表示最左边的前缀字符串、分隔符和最右边的后缀字符串。你可以尝试将代码修改为使用mkSting('[', ', ', ']'),并观察修改后代码的输出。
我们把mkString方法的输出保存到一个变量之中,再调用println方法打印这个变量。我们本可以在整个map方法之外再封装println方法进行打印,不过此处引入新变量能增强代码的可读性。