深入理解Scala

978-7-115-36554-5
作者: 【美】Joshua D. Suereth
译者: 杨云
编辑: 陈冀康

图书目录:

详情

本书是一本使用Scala的实用指导书,深入的探讨了一些必要的主题。这本书撷取了入门书籍忽略的主题,使读者可以写出符合Scala惯用法的代码,理解使用高级语言特性时的取舍。尤其是,这本书详细讲解了Scala的隐式转换和类型系统,然后才讨论怎样使用它们来极大的简化开发。本书提倡“混合风格”的Scala编程,结合使用不同编程范式以达成更大的目标。

图书摘要

深入理解Scala

Scala IN DEPTH

〔美〕 Joshua D. Suereth 著
杨云 译
人民邮电出版社

北京

图书在版编目(CIP)数据

深入理解scala /(美)苏瑞茨(Suereth,J.D.)著;杨云译.--北京: 人民邮电出版社,2015.1

ISBN 978-7-115-36554-5

Ⅰ.①深… Ⅱ.①苏…②杨… Ⅲ.①JAVA语言—程序设计 Ⅳ.①TP312

中国版本图书馆CIP数据核字(2014)第251097号

版权声明

Simplified Chinese-linguage edition copyright ©2014 by Posts & Telecom Press.All rights reserved.

Original English language edition entitled Scala in Depth ,by Joshua D.Suereth,published,published by Mnning Publicatio ns Co.,209 Bruce Park Avenue,Greenwich,CT 06830.Copyright ©2012 by Manning Publications Co.

本书中文简体字版有Manning Publications Co.授权人民邮电出版社独家出版。未经出版者书面许可,不得以任何方式复制或抄袭本书内容。

版权所有,侵权必究。

内容提要

Scala是一种多范式的编程语言,它既支持面向对象编程,也支持函数式编程的各种特性。

本书深入探讨了Scala里几个较为复杂的领域,包括类型系统的高阶内容、隐式转换、特质的组合技巧、集合、Actor、函数式编程的范畴论等,而且不是干巴巴地讲述语言和库的概念。本书充满各种实用的建议和最佳实战,可以帮助读者学习怎样把Scala里较少被掌握的部分应用到工作中。

本书不是Scala的入门级教程,而是适合有经验的Scala程序员向专家水平进阶的参考读物。本书适合想要了解Scala语言的底层机制和技术细节的读者阅读参考。

◆ 著 [美]Joshua D.Suereth

译 杨云

责任编辑 陈冀康

责任印制 张佳莹 彭志环

◆ 人民邮电出版社出版发行  北京市丰台区成寿寺路11号

邮编 100164  电子邮件 315@ptpress.com.cn

网址 http://www.ptpress.com.cn

北京艺辉印刷有限公司印刷

◆ 开本:800×1000 1/16

印张:18

字数:385千字  2015年1月第1版

印数:1-3000  2015年1月北京第1次印刷

著作权合同登记号 图字:01-2012-4605号

定价:59.00元

读者服务热线:(010)81055410 印装质量热线:(010)81055316

反盗版热线:(010)81055315

前言

Joshua Suereth 似乎是我所知的最全面的程序员之一,熟悉各种编程语言和技术。他是高性能系统、构建工具、类型理论以及其他很多领域的专家。同时他在教学方面也极有才能。这些特质的结合使《深入理解Scala》成为一本与众不同的书。

这本书深入探讨了Scala里几个较为复杂的领域,包括类型系统的高阶内容、隐式转换、特质的组合技巧、集合、Actor、函数式编程的范畴论等,而且不是干巴巴地讲述语言和库的概念。这本书里充满各种实用的建议和最佳实践来帮助读者学习怎样把Scala 里较少被掌握的部分应用到工作中。书中的解释和例子展现出Joshua用Scala构建大型可伸缩系统的丰富经验。

《深入理解Scala》不是一本入门级的书,而是有经验的Scala程序员向专家水平进阶的参考读物。书中所教的都是在构建灵活且类型安全的库时非常好用的技巧。其中很多都是“隐藏在民间”的技巧,第一次在本书中落在纸面上。

我还特别为另一件事而高兴:这本书填补了一个空白——它把正式的Scala语言规范中的关键部分解释给了不是专门研究语言的程序员们。Scala 是少数几个有正式的语言规范的编程语言之一。语言规范主要包含高度程序化的文本和数学公式,所以不是所有人都愿意一读。Joshua的书在解释语言规范里的概念时做到了既权威又可理解。

Martin Odersky

Scala语言的创始人

首席程序员

RESEARCH GROUP,EPFL

关于本书

《深入理解Scala》是一本使用Scala的实用指导书,深入地探讨了一些必要的主题。这本书撷取了入门书籍忽略的主题,使读者可以写出符合Scala惯用法的代码,理解使用高级语言特性时的取舍。尤其是,这本书详细讲解了Scala的隐式转换和类型系统,然后才讨论怎样使用它们来极大地简化开发。本书提倡“混合风格”的Scala编程,结合使用不同编程范式以达成更大的目标。

谁应当阅读本书

本书适合新或中等水平的Scala开发人员来提升他们的开发技能。这本书在覆盖Scala 中一些非常高阶的概念的同时,也尽量考虑了学习Scala的新手的需求。

本书的读者应当学过Java或别的面向对象语言。具有Scala经验可有助于读者阅读本书,但这种经验并不是必需的。本书覆盖Scala2.7.x到Scala2.9.x版本。

路线图

本书从讨论Scala的“禅”,也就是Scala的设计哲学开始——Scala是把多种概念混合,达成1+1 大于2 的效果。特别讨论了三组对立的概念:静态类型与表达力,函数式编程与面向对象编程,强大的语言特性与极简的Java集成。

第2章讨论Scala的核心规则。这些规则是每个Scala程序员都应当了解并在日常开发中使用的。这一章的内容适用每个Scala程序员,覆盖了那些使Scala如此卓越的基本内容。

第3章是关于编码风格及相关问题的插话。Scala带来了一些新东西,任何Scala编码风格指导都应当反映这些内容。有些来自流行的编程语言如Ruby和Java的常用规则实际上会妨碍你写出好的Scala代码。

第4章覆盖了Scala的mixin继承带来的面向对象设计的新问题。每个Scala程序员都感兴趣的一个主题是早期初始化,这个主题在别的书里很少谈到。

谈完面向对象后,本书接着讨论隐式转换系统。在第5章,我们不是简单地讨论最佳实践,而是深入理解Scala的隐式转换机制。这章对于所有想写出有表达力的库和代码的Scala程序员都是必读章节。

第6章专注于Scala的类型系统。这章讨论Scala里“类型”的各种表现形式,以及如何利用类型系统来强制约束。这章接着进入到对高阶类型的讨论,然后深入到存在类型的讨论作为结束。

第7章讨论Scala语言最高阶的运用模式——类型系统和隐式转换的交叉使用。这种交叉使用带来了很多有趣而强大的抽象,典型的就是类型类模式。

讨论完Scala最高阶的部分后,在第8章,我们讨论Scala的集合库。内容包括Scala 集合库的设计和性能,以及如何利用强大的类型机制。

第9章开始讨论Scala的Actor。Actor是一种并发机制,当正确使用时,可以提供极大的吞吐量和并行性。这一章从讨论基于Actor的设计入手,以展示Akka actors库默认提供的最佳实践作为结束。

第10章内容覆盖Java与Scala的集成。虽然Scala与Java的兼容性好于JVM上的大部分其他语言,但两者还是存在一些特性上的不匹配。这些不匹配的角落里容易发生Scala-Java集成问题,这章提供了几个简单规则来帮助避免这些问题。

第11章拿来了范畴论里的概念并使之实用化。在纯函数式编程里,很多来自范畴论的概念已经被应用到代码里。这些概念有点类似面向对象的设计模式,但是要抽象得多。虽然这些概念的名字挺吓人——这在数学上很常见——但它们其实极有实用价值。不讨论这些抽象概念就没法完整讲述函数式编程,本书尽力使这些概念更现实(不那么抽象)。

代码下载和约定

为了使代码显示区别于正文,本书中的所有代码都用等宽字体显示。很多代码清单里有一些用来指出关键点的注解。我已经尽量通过增加换行和使用缩进来调整代码格式,使注解能够在页面空间里显示完整,但偶尔还会有一些很长的语句不得不用换行连接符号。

书中所有例子的源代码可以在 www.manning.com/ScalainDepth 和 https://github.com /jsuereth/scala-in-depth-source获取。要运行代码示例,读者需要安装Scala以及(可选的) SBT构建工具。

全书包含很多代码示例,较长的代码有明显的清单标题,较短的代码直接显示在文本行之间。

作者在线

购买本书的同时,您获得了免费访问 Manning 出版社运营的私有网络论坛的权利,您可以在那里对本书做评论、询问技术问题、向作者和其他用户寻求帮助。要访问和订阅论坛,请在Web浏览器里打开地址www.manning.com/ScalainDepth。这个页面提供了在注册后如何使用论坛,有哪些可用的帮助,以及论坛的指导规则等信息。

Manning对读者的承诺仅是提供一个能够让读者之间、读者和作者之间进行有意义的对话的场所,并不承诺作者在论坛上的投入度。作者在论坛上的投入是义务的(无偿的)。我们建议您向作者提出一些有挑战的问题,以免他失去兴趣。

只要书还在印刷,作者在线论坛以及发生过的讨论将保持可访问。

作者简介

Josh Suereth 是Typesafe 公司(Scala 背后的公司)的一名高级软件工程师。从2007年了解到Scala这门美丽的语言后,他就成了Scala的狂热分子。他在2004年开始了软件开发者的职业生涯,一开始先在C++、STL和Boost上磨砺技能。当时Java正在狂热传播,他的兴趣迁移到Web部署的基于Java的解决方案来帮助健康部门发现疾病的爆发。

他在2007年将Scala引入公司的代码库,然后迅速染上了Scala狂热症,他对Scala IDE、maven-scala-plugin和Scala本身都做出了贡献。今日,Josh已经是好几个开源Scala 项目的作者,包括Scala自动化资源管理库和PGPsbt插件,他还是Scala生态系统的一些关键组件的贡献者,比如说 maven-scala- plugin。他现在就职于 Typesafe 公司,做包括从构建MSI到侦测性能问题等各种事情。

Josh定期地通过文章和演讲分享他的专业知识。他喜欢在海滩边散步还有喝黑啤。

致谢

在这本书出版的过程中得到了很多人的帮助。我将尽量全部列出,但我确信帮助我的人太多,实非我的小小脑袋能够全部记住。这本书让我知道我有非常多高水准的朋友、同事,还有家庭。

最应当感谢的是我的妻子和孩子,他们不得不忍受一个一直躲在角落里写书,该搭手帮忙的时候也不出来的丈夫和父亲。没有任何作者能够在没有家庭支持的情况下写一本书,我也不例外。

接着我要感谢Manning出版社和工作人员为使我成为一个真正的作者所做的事。他们不仅做了审阅、排版的工作,还帮助我提高有助于清晰沟通的写作技巧。对整个出版团队,我感激不尽,尤其要感谢Katherine Osborne 容忍我的不断拖稿,宾夕法尼亚州的荷兰语式的语句和经常的拼写错误。Katherine非常注意搜集对本书的读者之声,那些读过MEAP(早期发行版)的读者应该能注意到本书的进步。

下一个应该感谢的群体是帮助我提高技术材料和文字写作的 Scala 专家和非专家们。在我写本书的差不多同时期,Tim Perret 正在为Manning 出版社写《Lift in Action》。和Tim的讨论既有益又激励。对我不幸的是他先写完了他那本书。Justin Wick 是本书很多内容的审阅者和协作者,并最终帮助我使这本书所针对的读者范围比我开始时所想的大得多。他同时也是付印前最后一个审阅手稿和代码的人。Adriaan Moor,一如既往地在讨论类型系统和隐式解析时指出我所有的错误,使讨论既实用又正确。Eric Weinberg 是我的老同事,帮助提供了本书中给非Scala 程序员的指导。Viktor Klang 审阅了“Actors”章节(和整本书)并提供了改进建议。也要感谢Martin Odersky 的支持,感谢他给本书写序。感谢Josh Cough,他是一个能在需要的时候跟我激荡创意的家伙,还有 Peter Simany,感谢他用电子邮件发给我对于整本书的非常详尽、细致、完整、极好的评审意见。

Manning 还联系了以下这些评审者,他们在不同阶段阅读了本书的手稿,我想在这里感谢他们无价的洞见和评论:JohnC.Tyler、Orhan Alkan、Michael Nash、John Griffin、Jeroen Benckhuijsen、David Biesack、Lutz Hankewitz、Oleksandr Alesinskyy、Cheryl Jerozal、Edmon Begoli、Ramnivas Laddad、Marco Ughetti、Marcus Kazmierczak、Ted Neward、Eric Weinberg、Dave Pawson、Patrick Steger 、Paul Stusiak、Mark Thomas、David Dossot、Tariq Ahmed 、KenMc Donald、Mark Needham和James Hatheway。

最后,我要感谢所有MEAP版的评审者,他们给予我非常有价值的反馈,感谢他们的支持和本书付印前所得到的审阅意见。他们不得不忍受大量的拼写错误,使这本书由粗糙的初稿转变为最终的版本。

自序

2010 年秋,Manning 出版社的Michael Stephens 联系我写一本关于Scala 的书。当时我就职于一家主营虚拟化/安全方面的小创业公司,期间我学习并在工作代码中应用了Scala语言。在Michael和我的第一次会谈中我们讨论了Scala的生态系统和什么样的书对社区最有价值。

我认为Scala需要一本“实用Scala”这样的书来指导那些Scala新人。Scala是一种美丽的语言,但它一下子带给程序员很多新概念。我曾看着Scala社区慢慢地识别出最佳实践,慢慢地发展出完全属于“Scala”的编码风格,但我一直不确定我是不是写这本书的合适人选。当各种条件逐渐齐备——我对这个主题充满激情,有足够的自由时间来做研究,有社区牛人的支持——于是我决定写这本书。

在写作过程中我学到了很多,写这本书耗时如此之久的原因之一在于 Scala 的不断进化和不断涌现的新最佳实践。另一个原因则是我意识到自己的知识在 Scala 的某些领域非常不足。我想在这里告诉所有有志写书的作者,写书的过程能够让你成为专家。在开始写书前你可能觉得自己本来就是专家,但我要告诉你真正的专业技能是在教导他人,是在尽力把复杂的概念清晰地解释给你的读者的过程中伴随着血、汗和泪成长起来的。

如果没有一直支持我的妻子、伟大的出版社、了不起的Scala程序员社区和愿意阅读我不同阶段的手稿,指出拼写错误,给出改进建议的读者,我绝不可能完成写这本书的旅程。感谢你们让这本书远超我独自一人能够达到的水平。

第1章 Scala——一种混合式编程语言

本章包括的内容:

简要介绍Scala语言

剖析Scala语言的设计思想

Scala 是一种将其他编程语言中的多种技巧融合为一的语言。Scala 尝试跨越多种不同类型的语言,给开发者提供面向对象编程、函数式编程、富有表达力的语法、静态强类型和丰富的泛型等特性,而且全部架设于 Java 虚拟机之上。因此开发者使用Scala时可以继续使用原本熟悉的某种编程特性,但要发挥Scala的强大能力则需要结合使用这些有时候相互抵触的概念和特性,建立一种平衡的和谐。Scala对开发者的真正解放之处在于让开发者可以随意使用最适合手头上的问题的编程范式。如果当前的任务更适合用命令式的设计实现,没什么规定禁止你写命令式的代码,如果函数式编程和不可变性(immutability)更符合需要,那程序员也可以尽管用。更重要的是,面对有多种不同需求的问题领域(problem domain),你可以在一个解决方案的不同部分采用不同的编程方法。

1.1 Scala的设计哲学

为了理解Scala的哲学,我们需要理解产生Scala的环境:Java生态圈Java(TM)语言在1995年左右进入计算机科学领域,产生了巨大的影响。Java和运行Java的虚拟机开始慢慢地变革了我们的编程方法。在那时候,C++正如日中天,开发者正在从纯C风格的编程转而开始学习如何有效地使用面向对象编程方法。尽管C++有很多的优点,但它也有一些痛点,比如难以分发库(distributing libraries)以及其面向对象实现的复杂度。

Java语言通过提供限制了部分能力的面向对象特性和使用Java虚拟机,同时解决了这两个痛点。Java虚拟机(JVM)允许代码在一个平台上编写和编译后,几乎不费多大劲就能分发到其他的平台上。尽管跨平台问题并没有就此消失,但是跨平台编程的成本极大地降低了。

随着时间的推移,JVM的执行效率越来越高,同时Java社区不断成长。HotSpot (TM)优化技术被发明出来,这样就可以先探测运行环境再进行针对性的代码优化。虽然这使得 JVM 启动速度变慢,但是之后的运行时性能则变得很适合于运行服务器之类的应用。尽管最初并非为企业服务器领域设计的,JVM 开始在此领域大行其道。于是人们开始尝试简化企业服务器应用开发,Enterprise Java Beans(TM)和较新的 Spring Application Framework(TM)出现,帮助程序员更好地利用 JVM 的能力。Java 社区发生了爆炸式的成长,创造出成百万的易于使用的库。“只要你能想到的,基本都能找到Java库”成为一个职场口号。Java语言持续地缓慢进化,努力维持住其社区。

与此同时,部分开发者开始延展他们的羽翼,触及了Java本身设计上的局限之处。Java 简化了一些(编程元素),而社区中的部分成员需要增加一些复杂的,但是可控的元素。第二波创造JVM语言的浪潮掀起并持续至今。Groovy、JRuby、Clojure和Scala 等语言开始将Java程序员带入一个新时代。我们不再局限于一种语言,而是可以有多种选择,每一种语言都有不同的优点和弱点。Scala是其中较为流行的一种。

Scala语言创造者Martin Ordersky是javac 编译器的作者,也是他将泛型引入Java 语言中。Scala语言衍生自Funnel语言。Funnel语言尝试将函数式编程和Petri网结合起来,而Scala的预期目标则是将面向对象、函数式编程和强大的类型系统结合起来,同时仍然要能写出优雅、简洁的代码。将以上多种概念混合的目的是创造出一种既能让程序员真正用起来,同时又能用来研究新的编程范式的语言。事实上它取得了巨大的成功——它作为一种可行的有竞争力的语言已经开始被产业界采用。

要想掌握Scala,你需要理解多种混合在一起的概念。Scala试图将以下三组对立的思想融合到一种语言中。

函数式编程和面向对象编程。

富有表达力的语法和静态类型。

高级的语言特性同时保持与Java 的高度集成。

我们来看一下这些特性是如何融合在一起的。先从函数式编程和面向对象编程概念开始。

1.2 当函数式编程遇见面向对象

函数式编程和面向对象编程是软件开发的两种不同途径。函数式编程并非什么新概念,在现代开发者的开发工具箱里也绝非是什么天外来客。我们将通过Java生态圈里的例子来展示这一点,主要来看Spring Application framework 和Google Collections 库。这两个库都在 Java 的面向对象基础上融合了函数式的概念,而如果我们把它们翻译成Scala,则会优雅得多。在深入之前,我们需要先理解面向对象编程和函数式编程这两个术语的含义。

面向对象编程是一种自顶向下的程序设计方法。用面向对象方法构造软件时,我们将代码以名词(对象)做切割,每个对象有某种形式的标识符(self/this)、行为(方法)、和状态(成员变量)。识别出名词并且定义出它们的行为后,再定义出名词之间的交互。实现交互时存在一个问题,就是这些交互必须放在其中一个对象中(而不能独立存在)。现代面向对象设计倾向于定义出“服务类”,将操作多个领域对象的方法集合放在里面。这些服务类,虽然也是对象,但通常不具有独立状态,也没有与它们所操作的对象无关的独立行为。

函数式编程方法通过组合和应用函数来构造软件。函数式编程倾向于将软件分解为其需要执行的行为或操作,而且通常采用自底向上的方法。函数式编程中的函数概念具有一定的数学上的含义,纯粹是对输入进行操作,产生结果。所有变量都被认为是不可变的。函数式编程中对不变性的强调有助于编写并发程序。函数式编程试图将副作用推迟到尽可能晚。从某种意义上说,消除副作用使得对程序进行推理(reasoning)变得较为容易。函数式编程还提供了非常强大的对事物进行抽象和组合的能力。

函数式编程和面向对象编程从不同的视角看待软件。这种视角上的差异使得它们非常互补。面向对象可以处理名词而函数式编程能够处理动词。其实近年来很多Java 程序员已经开始转向这一策略(分离名词和动词)。EJB 规范将软件切分为用来容纳行为的 Session bean 和用来为系统中的名词建模的 Entity bean。无状态Session bean 看上去就更像是函数式代码的集合了(尽管欠缺了很多函数式代码有用的特性)。

这种朝函数式风格方向的推动远不止 EJB 规范。Spring 框架的模板类(Template classes)就是一种非常函数式的风格,而Google Collections 库在设计上就非常的函数式。我们先来看一下这些通用的Java库,然后看看Scala的函数式和混合面向对象编程能怎样增强这些API。

1.2.1 重新发现函数式概念

很多现代API设计时都融入了函数式编程的好东西而又不称自己是函数式编程。对于Java 来说,像Google Collections 和Spring 应用框架以Java 库的形式使Java 程序员也能接触到流行的函数式编程概念。Scala更进一步,将函数式编程直接融合到了语言里。我们来将流行的 Spring 框架中的 Jdbc Template 类简单地翻译成 Scala,看看它在Scala 下会是什么样子。

清单1.1 Spring的JdbcTemplate类上的查询方法

现在,来直译一下,我们把接口转换为有相同方法的特质(trait)。

清单1.2 查询方法的Scala直译

简单的直译也很有意思,不过它还是非常的Java。我们现在来深挖一下,特别看看PreparedStatementCreator和RowMapper接口。

清单1.3 PreparedStatementCreator 接口

PreparedStatementCreator 接口只有一个方法。这个方法接受JDBC连接,返回PreparedStatement. RowMapper 接口看上去也差不多。

清单1.4 RowMapper接口

Scala 提供了一等函数(first-class function),利用这个特性,我们可以把JdbcTemplate 查询方法改成接受函数而不是接口作为参数。这些函数应该跟接口里的基础方法有相同的签名。本例中,PreparedStatementCreator 参数可以替换为一个函数,这个函数接受Connection,返回PreparedStatement. RowMapper 可以替换成一个接受ResultSet 和整数,返回某种对象类型的函数。更新后的Scala版本JdbcTemplate如下。

清单1.5 Spring的JdbcTemplate类的Scala版本初版

现在query方法变得更函数式了。它使用了称为租借模式(loaner pattern)的技巧。这种技巧的大意在于让一些主控的实体(controlling entity)——本例中是Jdbc Template——由它来构造资源,然后将资源的使用委托给另一个函数。本例中有两个函数和三种资源。同时,其名字JdbcTemplate隐含的意思是它是个模板方法,其部分行为是有待用户去实现的。在纯面向对象编程中,这一点通常通过继承来做到。在较为函数式的方法中,这些行为碎片(behavioral pieces)成为了传给主控函数的参数。这样就能通过混合/匹配参数提供更多的灵活性,而无需不断地使用子类继承。

你可能会奇怪为什么我们用AnyRef作为第二个参数的返回值。Scala中的AnyRef 就相当于Java里的java.lang.Object。既然Scala支持泛型,即使要编译成jvm1.4字节码,我们也应该进一步修改接口移除AnyRef,允许用户返回特定类型。

清单1.6 Spring的JdbcTemplate类的类型化后的版本

仅稍做转换,我们就创建了一个直接使用函数参数的接口。这比之前略为函数式一点,仅仅是因为Scala的函数特质允许组合。

当你读完本书的时候,你将能做出与此接口完全不同的设计。不过我们现在还是继续查看Java 生态圈里的函数式设计。尤其是Google Collections API。

1.2.2 Google Collections 中的函数式概念

Google Collections API 给标准Java 集合库增加了很多功能,主要包括一组高效的不可变数据结构和一些操作集合的函数式方法,主要是Function接口和Predicate(谓词)接口。这些接口主要用在通过Iterables和Iterators类上。我们来看下Predicate接口的使用方法。

清单1.7 谷歌集合库的Predicate接口

Predicate接口非常简单。除了equals方法,它就只有一个“apply”方法。apply方法接受参数,返回true或false。Iterators/Iterables的“filter”方法用到了这个接口。filter 方法接受一个集合和一个谓词作为参数,返回一个新集合,仅包含被predicate的apply 方法判定为true的元素。在find方法里也用到了Predicate接口。find方法在集合中查找并返回第一个满足predicate的元素。下面列出filter和find 方法签名。

清单1.8 迭代器的filter 和find方法

另外还有个Predicates类,里面有一些用于组合断言(与和或等)的静态方法,还有一些常用的标准谓词,如“not null”等。这个简单的接口让我们可以用很简洁的代码通过组合的方式实现强大的功能。同时,因为 predicate 本身被传入到 filter 函数里面(而不是把集合传入到predicate里),filter函数可以自行决定执行断言的最佳方法或时机。比如(filter 背后的集合)数据结构有可能决定采用延迟计算(lazily evaluating)断言的策略,那它可以返回原集合的一个视图(view)。它也可能决定在创建新集合的时候采用某种并行策略。关键是这些都被抽象掉了,使得库可以随时改进而不影响用户的代码。

Predicate接口自身也很有趣,因为它看上去就像个简单的函数。这个函数接受某个类型T,返回一个布尔值,在Scala 里用T => Boolean 表示。我们用Scala 来重写一下filter/find方法,看看它们的函数签名怎样定义。

清单1.9 迭代器的filter 和find方法的Scala 版本

你会立刻注意到Scala 里无需显示的标注“?superT”,因为Scala 的Function 接口已经恰当地标注了协变(Covariance)和逆变(Contravariance)。如果某类型可以强制转换为子孙类,我们称为协变(+T 或? extends T),如果某类型可以强制转换为祖先类,我们称为逆变(-T 或? super T)。如果某类型完全不能被强制转换,就称为不变(Invariance)。在这个例子里,断言的参数可以在需要的时候强制转换为其祖先类型。举例来说,如果猫是哺乳动物的子类,那么一个针对哺乳动物的断言也能用于猫的集合。在Scala中,你可以在类定义的时候指定其为协变/逆变/不变。

那么在Scala里怎么组合断言呢?我们可以利用函数式组合的特性非常方便地实现一些组合功能。我们来用Scala实现一个新的Predicates模块,这个模块接受(多个)函数断言作为参数,提供它们的常用组合函数。这些组合函数的输入类型应该是T =>Boolean,输入类型也是T=> Boolean。初始的(组合前的)断言应该也是T=>Boolean 类型。

清单1.10 Predicates的Scala版

现在我们开始踏入函数式编程的领域了。我们定义了一等函数(first-class function),然后把它们组合起来提供新的功能。你应该注意到了or方法接受两个断言,f1和f2,然后产生一个匿名函数,这个函数接受参数t,然后把f1(t)和f2(t)的结果“or”一下。函数式编程也更充分地利用了泛型和类型系统的能力。Scala 投入了很多心血来减少使用泛型时的困难,使泛型可以被“日常使用”。

函数式编程并不仅仅就是把函数组合起来而已。函数式编程的精髓在于尽可能地推迟副作用。上例中的Predicate对象定义了一个简单的组合机制,只是用来组合谓词(而不执行)。直到实际的谓词传递给 Iterables 对象后才产生副作用。这个区分很重要。我们可以用Preicate对象提供的辅助方法把简单的谓词组合成很复杂的谓词。

函数式编程给我们提供了手段来推迟程序中改变状态的部分。它提供了机制让我们构造“动词”,同时又推迟副作用。这些动词可以用更方便推理(reasoning)的方式组合起来.直到最后,这些“动词”才被应用到系统中的“名词”上。传统的函数式编程风格是要求把副作用推到越晚越好。混合式面向对象-函数式编程(OO-FP),则是一种混合式风格(the idioms merge)

接下来我们看看Scala怎么解决类型系统和富有表达力的代码之间的矛盾。

1.3 静态类型和表达力

开发人员中有一个误解,认为静态类型必然导致冗长的代码。之所以如此是因为很多继承自C的语言强制要求程序员必须在代码中多处明确地指定类型。随着软件开发技术和编译器理论的发展,情况已经改变。Scala 利用了其中一些技术进步来减少样板(boilerplate)代码,保持代码简洁。

Scala做了以下几个简单的设计决策,以提高代码表达力。

把类型标注(type annotation)换到变量右边。

类型推断。

可扩展的语法。

用户自定义的隐式转换。

我们先看看Scala是怎么改变类型标注的位置的。

1.3.1 换边

Scala把类型标注放在变量的右侧。像Java或C++等几种静态类型语言,一般都必须声明变量、返回值和参数的类型。在指定变量或参数的类型时,延续自C的做法是把类型标注放在变量名的左边。对于方法的参数和返回值来说,这是可以接受的,但在构造不同风格(style)的变量时就容易产生混淆。C++是最好的例子,它有很丰富的变量修饰符选项,比如volatile、 const、指针和引用等。

清单1.11 C++里的整型变量示例

把变量的类型和其修饰符混在一起的做法引致一些极其复杂的类型定义。而Scala,和其他几种语言,则把类型标注放在变量的右边。把变量类型和修饰符分开能帮助程序员在读代码时减少一些复杂性。

清单1.12 Scala里的整型变量示例

上例演示了仅仅把类型标准搬到变量右边,我们就可以使代码简化不少,而这还不是全部,Scala能通过类型推断进一步减少语法噪音。

1.3.2 类型推断

只要能够进行类型推断, Scala 就会执行类型推断,从而进一步降低语法噪音(syntactic noise)。类型推断是指编译器自行判断类型标注,而不是强迫用户去指定。用户当然可以提供类型标注(如果他想),但他也可以选择让编译器来干这活。

清单1.13 Scala里的类型推导

这个特性能够极大地减少在其他强类型的语言中常见的语法噪音。Scala更进一步对传递给方法的参数也进行某种程度的类型推断,特别是对(作为参数的)一等函数。如果已知一个方法接受一个函数参数,编译器能够推断出函数字面量(function literal)里面使用的类型。

清单1.14 函数字面量类型推导

1.3.3 抛开语法

Scala 语法采取了一种策略:如果一行代码的含义非常明确,就可以抛弃掉一些冗长的语法。这个特性可能会让刚学习Scala的用户感到困惑,但如果使用得当,这个特性是极其强大的。我们来看个重构代码的例子,从一个完全符合Scala标准语法的代码,简化为Scala社区的惯用写法。下面是Scala实现的Quicksort函数。

清单1.15 冗长版的Scala快速排序

这段代码接受一个 T 类型的列表,T 可以被隐式转换为 Ordered[T]类型(T <%Ordered[T])。我们会在第6章详细讨论类型参数及其约束,目前先不要关注于此。简单来说,我们需要一个列表,里面的元素是可以排序的,所以该元素应该有个判断是否小于的方法“<”。然后我们检查列表,如果是空列表或 Nil,我们就返回个 Nil 列表。如果列表里有内容,我们取出列表的头(x)和尾(xs)。我们用头元素来把尾列表切分成两个列表。接着我们对这两个列表分别递归调用quicksort方法。在同一行上,我们把排序后的列表和头元素结合为一个完整的(排序后的)列表。

你可能会想,“哇哦,Scala 代码看上去真丑”。就这个例子来说,你可能是对的。代码相当凌乱,难以阅读。有很多语法噪音掩盖了代码本身的含义。不仅如此,qsort 后面还有很多吓人的类型信息。让我们拿出手术刀,割掉那些讨厌的玩意。首先,Scala 可以自行推断分号。编译器会假定一行结束就是一个表达式的结束,除非你在行末留了什么未完结的语法片段,比如方法调用前的那个“.”(like the.beforea method call)。

光删除分号显然没多大帮助。我们还需要应用“操作符”(operator notation)。操作符是Scala 的一个能力,可以把方法当作操作符。无参数的方法可以用作后缀操作符(postfix operator),只有一个参数的方法可以当作中缀操作符(infix operator)。还有一些对特殊字符的专门规定,比如方法名的最后一个字符如果是“:”,则方法的调用方向反转。下面的代码演示了这些规则。

清单1.16 操作符

在定义匿名函数时(又称 lambda),Scala 提供了占位符语法。可以使用“_”关键字作为函数参数的占位符。如果使用多个占位符,每个相应位置的占位符对应于相应位置的参数。这种写法通常在定义很简单的函数时使用,比如我们的 Quicksort 例子里面比较元素大小的那个函数字面量。

我们可以结合使用占位符语法和操作符来改进我们的快速排序算法。

清单1.17 较简洁版的Scala快速排序

Scala 不仅为简单场景提供了语法糖,它还提供了隐式转换和隐式参数机制让我们可以扭曲(bend)类型系统。

1.3.4 隐式转换概念早已有之

Scala 隐式转换是老概念的新用法。我是在 C++的基础类型上第一次接触到隐式转换的概念。只要不损失精度,C++可以自动转换基础类型,比如我可以在声明long值的时候给它个 int 。对于编译器来说,实际的类型“double”,“float”, “int”和“long”型都是不同的,但在混用这些值时编译器尝试智能地去“做正确的事”(Do the Right Thing (TM))。Scala 提供了相同的机制,但是是作为一个语言特性给大家使用(而不是只让编译器用)。

Scala会自动地加载scala.Predef对象,使它的成员方法对所有程序可用。这样可以很方便地给用户提供一些常用函数,比如可以直接写 println 而不是 Console.println 或System.out.println。Predef 还提供了“基础类型拓宽”(primitive widenings)。也就是一些能够把低精度类型自动转换为高精度类型的隐式转换。我们来看一下为 Byte 类型定义的转换方法。

清单1.18 scala.Predef对象里的字节转换方法

这些方法只是简单地调用运行时转换(runtime-conversion)方法。方法名前面的 implicit关键字说明编译器会在需要对应的类型时尝试对Byte调用对应的方法。比如说,如果我们给一个需要 Short 类型的方法传了一个 Byte,编译器会调用隐式转换方法byte2short。Scala还把这个机制更进一步:如果对一个类型调用一个它没有的方法,Scala 也会通过隐式转换来查找这个方法。这比仅仅提供基础类型转换提供了更多的便利。

Scala也把隐式转换机制作为扩展Java基础类型(Integer、 String、Double等)的一种手段。这允许Scala直接使用Java类以方便集成,同时提供更丰富的方法(method)以便利用Scala更为先进的特性。隐式转换是非常强大的特性,也因此引起一些人的疑虑,关键在于知道如何以及何时使用隐式转换。

1.3.5 使用 Scala 的 implicit 关键字

用好隐式转换是操纵Scala类型系统的关键。隐式转换的基础应用场景是按需自动地把一种类型转换为另一种,但它也可以用于有限形式的编译时元编程(limited forms of compiler time metaprogramming)。要使用隐式转换必须把它关联到某个作用域。可以通过伴生对象或明确的导入来做关联。

implicit关键字在Scala里有两种不同用法。第一种用法是给方法声明一种特殊参数,如果编译器在作用域里找到了合适的值就会自动传递给方法。这可以用来把某API的某些特性限定在某个作用域里。因为implict 采用了继承线性化(inheritance linearizion)的查找策略,所以可以用来修改方法的返回值。这使用户可以写出非常高级的API以及玩一些类型系统的小把戏,在Scala collections API 里就使用了这种技术。这些技术会在第7章详加解释。

implicit 关键字的另一种用法是把一种类型转换为另一种。有两种场景会发生隐式转换,第一种场景是当你给一个函数传递参数的时候,如果Scala发现函数需要的参数类型(跟传给它的)不一样,Scala 会首先检查类型继承关系,如果没找到,就会去查找有没有合适的隐式转换方法。隐式转换方法只是普通的方法,用 implicit 关键字做了标注,该方法接受一个参数,返回某些结果(译注:实际上是接受转换前的参数,返回转换后的结果,然后Scala用转换后的结果作为参数去调之前那个函数)。第二种场景是当调用某类型的某方法时,如果编译器发现该类型没有这个方法,Scala 会对该查找适用于该类型的隐式转换,直到找到一个转换后具有该方法的结果,或者找不到(编译出错)。这种做法在Scala 的“pimp my library”模式中得以应用,这些内容也会在第7 章详解。

这些特性的组合给Scala带来了非常有表达力的语法,同时保持其高级的类型系统。创造有表达力的库需要深入理解类型系统,也必须彻底理解隐式转换的知识。第6章会全面地覆盖类型系统的知识。Scala类型系统跟Java也能很好地交互,这是Scala的关键设计之一。

1.4 与JVM的无缝集成

Scala的吸引力之一在于它与Java和JVM的无缝集成。Scala与Java有很强的兼容性,比如说Java类可以直接映射为Scala类。这种紧密联系使Java到Scala的迁移相当简单,但在使用Scala 的一些高级特性时还是需要小心的,Scala 有些高级特性是Java 里没有的。在Scala语言设计时已经小心地考虑了与Java无缝交互的问题,用Java写的库,大部分可以直接照搬(as-is)到Scala里。

1.4.1 Scala 调用 Java

从Scala里调用Java库是透明的,因为Java惯用法直接对应到Scala惯用法。Java 类变成Scala类,Java接口变成Scala抽象特质(trait),Java静态成员被加入Scala伪对象(pseudo Scala object)。以上结合Scala 的包导入机制和方法访问机制,使Java 库感觉就像原生Scala库一样。虽然有过度简化之嫌,但一般情况下是直接就能用。举例来说,我们有个Java类,它有构造器,有一个成员方法和一个静态辅助方法。

清单1.19 简单Java对象

现在我们在Scala里用这个Java类。

清单1.20 在Scala里使用简单Java对象

这种映射非常自然,使用Java类库成为用Scala做开发时很自然的事。除了有这种紧密集成,你通常还能找到Java 库的瘦Scala 包装(thin Scala wrapper),提供一些Java API无法提供的高级特性。尝试在Java中使用Scala库时,这些特性就变得很凸显。

1.4.2 Java 调用 Scala

Scala尝试以最简单的方式把其特性映射到Java。大部分Scala特性可以一对一地简单映射为Java特性,比如类、抽象类、方法等。Scala有些相当高级的特性就难以简单的映射了,包括对象、一等函数和隐式转换等。

Scala对象映射到Java

虽然 Java 的静态(statics)映射为 Scala 对象,但 Scala 对象实际上是个单例类(singleton)的实例,在编译时此单例类命名为对象名后加个$符号。这个单例类里有个Module$静态成员,指向其唯一实例。Scala还提供了转发静态方法的能力,这些静态方法位于伴生类里(一个与object同名的类)。虽然Scala本身并没有使用静态方法,但是它们给从Java里调用Scala提供了便利的语法。

清单1.21 简单Scala对象

清单1.22 在Java里使用简单Scala对象

Scala函数映射到Java

Scala 鼓励使用作为对象的函数(function as object),或称一等函数。到Java1.6 为止,Java语言(和JVM虚拟机)都还没有这样的概念。因此Scala创造了函数特质符号(notion of Function traits),一共有23 个特质代表0 到22 个参数的函数。当编译器碰到需要把方法当做函数传递的场景时,就构造一个(参数数量)合适的特质的匿名子类。由于特质无法映射到Java,从Java中传递一等函数到Scala也就很难实现,但也不是完全没办法。

清单1.23 在Java里调用需要函数作为参数的Scala方法

我们在Scala里构造了一个抽象类,这样Java实现起来就比function特质要容易。虽然这稍微简化了Java端的实现,但还是没百分百地简化问题。Java类型系统和Scala对类型的编码中间还是存在不匹配,我们还是需要在调用Scala时对函数类型做强制转换。

清单1.24 在Java里实现一等函数

所以在组合使用Scala和Java时,使用一等函数和更函数式的编程方法是可能的。但是还存在其他的手段来达到这个目的。这方面更详细的讨论以及其他Java/Scala交互相关的问题请见第10章。如你所见,Scala可以很好地集成现有的Java程序,也可以和Java代码一起使用。Java/Scala交互并非在JVM上跑 Scala的唯一好处,JVM本身也带来了巨大的好处。

1.4.3 JVM 的优越性

前文曾经提过,Java的很多好处是JVM提供的。通过字节码,可以几乎原封不动地把库分发到很多不同的平台。JVM在很多平台上经过仔细的测试,而且经过了大规模的企业部署。不仅测试完善,还在Java平台的性能方面投入了极大关注。HotSpot编译器能在运行时对代码进行各种优化。用户可以简单地升级JVM,然后立刻体验到性能提升,而无需打补丁或重编译。

HOTSPOT-ING

在JVM上运行Scala的首要好处是HotSpot运行时优化器。它会对程序进行运行时分析,自动对JVM字节码进行调优。Scala运行于JVM上,自然就免费得到了这些优化。JVM每次发布都提升了HotSpot编译器,也就连带着提升了Scala的性能。HotSpot 编译器使用了多种技术,包括以下这些。

方法内联(Method inlining)。

栈替换(On Stack Replacement)。

逃逸分析(Escape Analysis)。

动态去优化(Dynamic De-optimization)。

方法内联是指 HotSpot 能够判断是否能在调用点直接把被调的小方法的内容嵌入进去。这是C++里我很喜欢的一项技术,而HotSpot能够动态判断这样做是否对性能有优化。栈替换指HotSpot能够判断一个变量应该放在栈(Stack)里还是堆(Heap)里。我记得用C++的时候一个大问题就是在声明变量的时候应该把它放在栈里还是堆里。现在 HotSpot 可以为我回答这个问题。逃逸分析是指HotSpot分析判断各种东西是否逸出(escape)了特定作用域。这项技术主要用来在同步方法调用限定于某个作用域时减少锁开销,但也可以用于其他情况。动态去优化是HotSpot的一个关键特性,它有能力判断一个优化是否事实上没有提升性能,然后取消该优化,改用其他优化。以上特性的组合构成了很有吸引力的图景,这就是为什么各种新/旧语言(比如Ruby)都很渴望在JVM上运行。

1.5 总结

本章中,你学到了一些 Scala 的设计理念。设计 Scala 的初衷在于把不同语言中的多种概念融合起来。Scala融合了函数式和面向对象编程,尽管显然Java也已经这么做了。Scala 精选其语法,极大地减少了语言中的繁冗之处,使一些强大的特性可以优雅地表达,比如类型推断。最后,Scala和Java能够紧密集成,而且运行在Java虚拟机上,这或许是让 Scala 变成一种实用选择的最重要的一点。几乎不花代价就可以把 Scala 用于我们的日常工作中。

因为Scala融合了多种概念,Scala的用户发现他们要在函数式编程、面向对象、与现有 Java 应用集成、富有表达力的库 API 和通过类型系统确保需求( enforcing requirements through the type system)等方面做微妙的平衡,根据手头的需求做出的决定往往是最佳的。正是这些对立概念的交织,使得Scala成长兴盛,而这也正是需要最多关注的地方。本书会帮你趟过艰难领域,让你看到Scala闪耀的地方。

我们先来了解一些每个Scala程序员在做Scala编程时都需要知道的关键概念。

第2章 核心规则

本章包括的内容:

使用Scala交互模式(Read Eval Print Loop 简称REPL)

面向表达式编程

不变性(Immutability)

Option类

本章内容覆盖了每个新Scala开发者都需要知道的几个主题。本章不会深入到每个主题里,但是会讲到可以让你自己去接着探索的程度。你将学会使用 REPL,学会如何利用这个工具做软件的快速原型开发。然后我们会学到面向表达式编程,并从另一个视角来看控制结构是怎么回事。在此基础上,我们来研究不变性,研究不变性为什么能帮助我们极大地简化程序,并且能帮助程序在并发环境下更好地运行。

2.1 学习使用Scala交互模式(REPL)

Scala 提供了很多学习材料帮助你学习核心语言内容,有很多在线的教程、示例和项目可以去研究。但是Scala提供的最重要的一个工具是交互模式(REPL)。REPL是一个交互式解释器,可以即时编译、运行代码并返回结果。假定你已经在机器上装好了Scala,也设置了正确的路径,那么在命令行下运行scala 命令就可以启动Scala REPL。启动Scala REPL 后屏幕上会输出如下内容:

后面的代码示例中,我会用scala>提示这是输入到REPL的内容。接下来的一行是REPL的输出。我们在REPL里快速做几个例子,看看会得到什么输出。

你应该注意到了在我们输入解释器的每个语句后,它会输出一行信息,类似 res0:java.lang.String = Hello。输出的第一部分是REPL 给表达式起的变量名。在这几个例子里,REPL为每个表达式定义了一个新变量(res0到res3)。输出的第二部分(:后面的部分)是表达式的静态类型。第一个例子的类型是 java.lang.String,最后一个例子的类型则是 scala.util.matching.Regex。输出的最后一部分是表达式求值后的结果的字符串化显示。一般是对结果调用 toString 方法得到的输出,JVM 给所有的类都定义了toString 方法。

如你所见,REPL 是一种测试 Scala 语言及其类型系统的强有力手段。不仅如此,大部分构建工具都提供了机制让你能加载当前工程的 classpath,然后启动 REPL。这意味着你可以在 REPL 里访问工程中引用的库和你自己的代码。你能够在 REPL 里调用API 和访问远端服务器。这是很棒的快速测试Web 服务或RESTAPI 的方法,也导向我称为实验驱动开发(Experiment Driven Development)的方法。

2.1.1 实验驱动开发

实验驱动开发就是开发者在写测试或生产代码前,先花点时间在交互环境或REPL 里做实验。这可以给你时间全面理解你需要打交道的软件或库的外部接口,并对其API 的优点和缺点得到点切身体会。这是学习新发布的 Web 服务或 RESTful API 或最新的Apache 库的极好办法,甚至可以用来学习你同事刚刚写出来的东西。在理解了 API 是怎么工作后,你就能更好地写自己的代码,或者开始写测试,如果你遵循测试驱动开发的话。

现在推动开发人员拥抱测试驱动开发(TDD)的呼声很高。TDD 要求开发者先写单元测试,然后写实现类。在你开始写测试前,你并不总是很清楚自己的API要定义成什么样的。TDD的一个组成部分就是通过写测试来定义API,这样你可以在(用户的)上下文里来看你的代码,可以感觉一下你自己愿意不愿意用你自己写的API。由于表达力(较差)的原因,强类型语言在应用 TDD 时可能会比动态语言碰到更多麻烦。实验驱动开发将“定义 API”这个步骤向前期推动一步,提前到了写测试代码之前。REPL 帮助开发者确保其设计的API在类型系统里能表达得出来。

Scala 是一种语法非常灵活的强类型语言,因此有时候需要用点手段欺骗类型系统才能达成你真正想要的API设计。因为很多开发者缺乏强类型理论基础,所以经常需要更多的实验。实验驱动设计(Experiment Driven Design)让你在REPL 里结合类型系统进行实验,以便为你的API提炼出最有效的类型定义。实验驱动设计主要用在给代码里添加大特性或领域对象的时候,不适合在添加新方法或者修bug时使用。

实验驱动设计在你定义领域特定语言时(DSL)也能帮上大忙。领域特定语言是用于特定领域的伪编程语言,这种语言专门用来解决手头的某个领域,比如说,从数据库里查询数据。DSL可以是内部的,在很多Scala库里都能看到的;也可以是外部的,比如SQL。在Scala社区,库开发者圈子里非常流行为自己的库创建一种DSL。比如Scala 的actors库定义了一种线程安全的发送和接收消息的DSL。

用 Scala 定义 DSL 的挑战之一在于有效地利用类型系统。设计良好的类型安全的DSL不仅应该富有表达力、易读,而且应该能在编译期而不是运行期捕捉到很多编程错误。同时静态类型信息也可以极大地提高性能。REPL 不仅能用来实验怎样表达一个特定领域,而且能帮助你确定你得表达式是否能编译。进行Scala开发时,有些人采用下面这种创造性的流程。

在REPL 里实验API 设计。

把能工作的API 拷贝到项目文件。

为API 写单元测试。

修改代码直到测试通过。

有效地使用实验驱动开发能够极大地提高你的API的质量。也会帮你在推进过程中更适应 Scala 的语法。不过这种做法有个大问题,就是并非所有能用 Scala 表达的 API 都能在REPL里表达。这是因为REPL是积极(eagerly)解析输入,即时解释执行的。

2.1.2 绕过积极(eaglerly)解析

Scala REPL 尝试尽可能快地解析输入。这个特点加上其他一些限制,意味着有些东西很难甚至是无法在REPL里表达的。其中一个难以表达的重要的功能是伴生对象和伴生类。

伴生对象和伴生类是一组用完全一样的名字定义的对象和类。用文件编译的方式很容易实现,就像这样简单的声明对象和类:

这些语句在REPL里也能执行,但是它们不会像真的伴生类那样起作用。为证明这一点,我们来做一些只有伴生对象能做,普通对象做不了的事:访问类的私有变量。

清单2.1 在REPL里使用伴生对象

为了解决这个问题,我们需要把这些对象嵌入解释器里某个能访问到的其他作用域里。我们现在来把它们放入某个作用域里,以便能同时解释/编译类和伴生对象。

清单2.2 在REPL里使用伴生对象的正确方法

我们在这创建了一个holder对象。这给了我们一个可访问的作用域,也把REPL的编译推迟到holder对象关闭的时候。这样我们就可以在REPL里测试/定义伴生对象了。

2.1.3 无法表现的语言特性

即使绕过了积极解析,也还有一些语言特性无法在 REPL里重现。大多数这种问题都跟包、包对象、包可见性约束等问题有关。尤其是你无法像在源代码文件里一样有效地在 REPL里创建包和包对象。这也意味着其他跟包有关的语言特性,特别是使用private关键字实现的可见性限制也无法在REPL里表达。包通常用来为你的代码设定命名空间,以便与你可能使用的其他类库分开。通常情况下你不需要在 REPL里用到它,但是可能有些时候你需要把玩一些 Scala 的高级特性,比如包对象和隐式解析(implicit resolution),这时你可能会想做点实验驱动开发。但是这种场景下,你无法在REPL里去表达。

清单2.3 在REPL里无法表达的语言特性

请不要绝望。如我之前说过的,大部分构建工具可以让你启动一个针对你当前工程的Scala REPL。作为最后的手段,你可以在Scala 文件里把玩那些高级概念,重编译然后重启REPL会话。

另外还有个工具叫做JRebel(http://zeroturnaround.com/software/jrebel/),它可以动态地在运行中的JVM里重载类文件。JRebel团队非常慷慨地为Scala中的使用提供了免费许可。利用这工具结合某种形式的持续编译——大部分 Scala 构建工具都提供的这一特性——你可以在修改工程文件后立刻在REPL会话里得到修改后的行为。对于maven-scala-plugin,持续编译的细节见其网站: http://scala-tools.org/mvnsites/maven-scala- plugin/usage_cc.html。Simple BuildTool (http://code.google.com/p/simple-build-tool/)(译者注:已经搬迁到github 了,https://github.com/harrah/xsbt/wiki)提供了CC任务来做持续编译。不管用哪种构建工具都必须和JRebel类加载器集成以便实现动态类重载。这个技巧有点过于细节,而且可能会变,所以如果需要帮助请参考你用的构建工具的文档或者JRebel网站。

在尝试创建大而复杂的系统前,你可以先利用REPL来实验Scala代码,获得一些真实的感觉。软件开发中,在开发一个新特性前,对当前系统得到一个稍微深入一些的理解(而不只是草草看过)往往是很重要的。Scala REPL 可以让你投入最少的时间达成对系统的理解,还可以提高你的开发技巧。本书全文穿插着很多REPL的例子,因为它是教学Scala的最好工具。我经常完全通过REPL运行示例,而不是采用Java开发时的标准做法,先写main方法或者单元测试。

REPL也是开始学习面向表达式编程的极佳方法。

2.2 优先采用面向表达式编程

面向表达式编程是个术语,意思是在代码中使用表达式而不用语句。表达式和语句的区别是什么?语句是可以执行的东西,表达式是可以求值的东西。在实践中这有什么意义呢?表达式返回值,语句执行代码,但是不返回值。本节我们将学习面向表达式编程的全部知识,并理解它对简化程序有什么帮助。我们也会看一下对象的可变性,以及可变性与面向表达式编程的关系。

作者注:语句VS表达式

语句是可以执行的东西,表达式是可以求值的东西。

表达式是运算结果为一个值的代码块。Scala 的一些控制块也是表达式。这意味着如果这个控制结构是有分支的,那么每个分支也必须被计算为一个值。if语句就是个极佳的例子。if语句检查条件表达式,然后根据条件表达式的值返回其中一个分支的结果。我们来看个简单的REPL会话:

如你所见,Scala的if块是个表达式。我们的第一个if块返回5,也就是表达式true 的结果。第二个if块返回hello,也就是表达式false的结果。要在Java里达到类似的目的,你得用下文所示的?:语法:

因此Java里的if块和?:表达式的区别在于if不被运算为一个值,Java里你不能把if块的结果赋值给一个变量。而Scala统一了?:和if块的概念,所以Scala里没有?:语法,你只需要用if块就够了。这只是面向表达式编程的开始,实际上,Scala绝大部分语句都返回其最后一个表达式的值作为结果。

2.2.1 方法和模式匹配

面向表达式编程挑战了其他语言的某些好的实践。用Java编程时,有个常用的实践是每个方法只有一个返回点。这意味着如果方法里有某种条件逻辑,开发者会创建一个变量存放最终的返回值。方法执行的时候,这个变量会被更新为方法要返回的值。每个方法的最后一行都会是个return语句。我们来看个例子。

清单2.4 Java惯用法:一个return语句

如你所见,result 变量用来存放最终结果。代码流过一个模式匹配,相应地设置出错字符串,然后返回结果变量。我们可以用模式匹配提供的面向表达式语法稍微改进一下代码。事实上,模式匹配上返回一个值,类型为所有case语句返回的值的公共超类。如果一个模式都没有匹配上,模式匹配会抛出异常,确保我们要么得到返回值要么出错。我们把上面的代码翻译成面向表达式的模式匹配实现。

清单2.5 使用面向表达式的模式匹配技巧重写的createErrorMessage

你应该注意到两件事。首先,我们把result变量改成了val,让类型推导来判断类型。因为我们不在需要在赋值后改变result的值,模式匹配应该能够判断唯一的值(和类型)。所以我们不仅减少了代码的大小和复杂度,我们还增加了程序的不变性。不变性(immutability)是指对象或变量赋值后就不再改变状态,可变性(mutability)是指对象或变量在其生命周期中能够被改变或操纵。我们将在下一节探讨可变性和面向表达式编程。你经常会发现面向表达式编程和不变对象合作无间。

我们做的第二件事是去掉了case语句里的所有赋值。case语句的最后一个表达式就是case语句的“结果”。我们可以在每个case语句里嵌套更深的逻辑,只要在最后能得到某种形式的表达式结果就行。如果我们不小心忘了返回结果,或者返回结果不对,编译器也会警告我们。

代码看上去已经简洁多了,不过我们还可以再改进一点。用Scala开发时,大部分开发者会避免在代码里使用return语句,而更喜欢用最后一句表达式作为返回值(这也是所有其他面向表达式语言的风格)。实际上,对于createErrorMessage方法,我们可以完全去掉result这个中间变量。我们看下最后改进的结果。

清单2.6 面向表达式的createErrorMessage方法最终版

你注意到我们甚至没为这个方法开个代码块吗?模式匹配是这个方法唯一一个语句,而它返回个字符串类型的表达式。我们完全把这个方法转化为了面向表达式的语法。注意到现在代码变得简洁得多,表达力也强多了吗?同时请注意,如果有任何类型不匹配或者无法走到的(unreachable)case语句,编译器会警告我们。

2.2.2 可变性

面向表达式编程一般与不变性编程(immutable programming )搭档得很好,但是与可变对象协作就没那么好了。不变性是个术语,拿对象来说,一旦对象构造完毕,其状态就不再改变。面向表达式编程和可变性(也就是对象在其生命周期中可以改变状态)混搭的时候,事情就变得复杂了一点。因为使用可变对象的代码一般倾向于用命令式(imperative)的风格编码。

命令式编码可能是你以前熟悉的风格。很多早期语言,如C、Fortran和Pascal都是命令式的。命令式代码一般由语句构成,而不是表达式。先创建对象,设定状态,然后执行语句,而语句会“操纵”或改变对象的状态。对那些没有对象的语言也是一样,只不过改成了操纵变量和结构。我们来看个命令式编码的例子。

清单2.7 命令式风格代码例子

注意看这里构造了一个Vector,然后通过magnify方法操纵其状态。而面向表达式的代码喜欢让所有的语句返回某个表达式或值,magnify 方法也不例外。在这个操纵对象的例子里,应该返回什么值呢?一个选择是返回刚被操纵过状态的对象。

清单2.8 状态可变的面向表达式方法示例

乍看上去这是个很棒的选择,但实际上有严重的缺陷。尤其难以判断对象的状态是什么时候被改变的,在跟不变对象混用时缺陷就更明显。假设 Vector2D 的-方法符合数学上的定义,请你试试看能否判断出下面这段代码在结束时会打印出什么值?

清单2.9 在表达式里混用可变和不可变对象

最后一句表达式的结果是什么呢?第一眼看上去结果应该是vector(3.0,3.0)减去vector(6.0,0.0),也就是(-3.0,3.0)。然而这里面每个变量都是可变的,也就是说变量的值是按照操作顺序修改的。我们来演算一下实际编译的结果。首先x,vector(1.0, 1.0)被放大3倍变成了(3.0,3.0)。然后我们用x减y,x变成了(2.0,4.0)。为什么?因为-方法右边的代码要先计算,其中(x-y)要先计算。接着我们再把 x 放大 3 倍,变成了(6.0,12.0)。最后我们用x减去x自己,结果是(0.0,0.0),你没看错,x自己减自己。为什么?因为减号左边的表达式和减号右边的表达式都是x变量开头的。因为我们使用可变对象,也就说每个表达式最后返回x自身。所以不管我们做什么,我们最后都是调用x-x,结果就是vector(0.0,0.0)。

因为存在这种混淆性,在用面向表达式编程时最好使用不可变对象。尤其在有操作符重载的场合下,比如上例。而在有些场景下可变性和面向表达式编程也可以合作得很好,尤其是在使用模式匹配或if语句时。

编码时一个常见的任务是根据某个值查找某个对象的值。这些对象可以是可变的,也可以是不变的。而面向表达式编程可以发挥作用的地方是简化查找。我们来看个简单的例子:根据用户点击的菜单按钮查找需要执行的操作。当按下菜单按钮的时候,我们从事件系统接受到一个事件。这个事件里有哪个按钮被按下的标记。我们要执行某种操作并返回状态。我们看下面的代码。

清单2.10 可变对象与表达式——正确的做法

注意看我们是怎么就地操纵对象并返回结果的。我们没有明确地用return语句,而是简单地写下我们打算返回的表达式。你可以看到这样的代码比创建一个用于存放返回值的变量来得简洁。也可以看到在表达式里混入操纵状态的语句导致代码的清晰性有所降低。这是我们更推崇不变性代码的原因之一,也就是下一节的主题。

面向表达式编程可以减少样板代码(boiler plate),使代码更优雅。其做法是让所有语句返回有意义的值,这样就可以减少代码的凌乱,增加代码的表达力了。现在是时候学习为什么我们要关注不变性了。

2.3 优先选择不变性

编程中的不变性指对象一旦创建后就不再改变状态。这是函数式编程的基石之一,也是JVM上的面向对象编程的推荐实践之一。Scala也不例外,在设计上优先选择不变性,在很多场景中把不变性作为默认设置。对此,你可能一下子会不适应。本节中,我们将学到不变性对于判等问题和并发编程能提供什么帮助。

Scala 里首先要明白的是不变对象和不变引用(immutable referene)的区别。Scala 里的所有变量都是指向对象的引用。把变量声明为val意味着它是个不变“引用”。所有的方法参数都是不变引用,类参数默认为不可变引用。创建可变引用的唯一方法是使用var 语法。引用的不变性不影响它指向的对象是否是不可变的。你可以创建一个指向不变对象的可变引用,反之亦然。这意味着,重要的是知道对象本身是不变的还是可变的。

对象是否有不变性约束不是那么显然的事。一般来说如果文档指出一个对象是不可变的,那么可以安全地假定它就是不可变的,否则就要小心。Scala 标准库里的集合类库把可变还是不变描述得很清楚,它有并列的两个包,一个放不变类,一个放可变类。

Scala 里不变性很重要,因为它有助于程序员推理代码。如果一个对象的状态不改变,那程序员找到对象创建的地方就可以确定其状态。这也可以简化那些基于对象状态的方法,这个好处在定义判等或写并发程序时尤其明显。

2.3.1 判等

优先选择不变性的关键原因之一在于简化对象判等。如果一个对象在生命周期中不改变状态,你就能为该类型对象创建一个既深又准的 equals 实现。在创建对象的散列(hash)函数时这一点也很关键。

散列函数返回对象的简化表现形式,通常是个整数,可以用来快速地确定一个对象。好的散列函数和equals方法一般是成对的,即使不通过代码体现,也会以某种逻辑定义的方式体现。如果一个对象的生命周期中改变了状态,那就会毁掉为该对象生成的散列代码。这又会连带着影响对象的判等测试。我们来看个非常简单的例子:一个二维几何点类。

清单2.11 可变的Point2类

Point2D类非常简单,它包含x和y值,对应x和y坐标轴上的位置。它还有个move 方法,用来在平面上移动点。想象我们要在这个二维平面上的特定点上贴个标签,每个标签就只用一个字符串表示。要实现这功能,我们会考虑定义一个Point2D到字符串的映射。出于性能考虑,我们打算写个散列函数并用 HashMap 来存放这个映射。我们来试试可行的最简单方法:直接对x和y变量做散列。

清单2.12 带有散列函数的可变Point2类

一开始代码执行结果看上去完全符合预期。但到我们试图构造一个与点x的值一样的新点对象时就不对了。这个新的点对象的散列值应该对应到map的同一块,然而判等检查却是否定的。这是因为我们没有为之创建自己的判等方法(equals)。默认情况下Scala 用对象位置判等法和散列,而我们只覆盖了散列代码(hashCode)方法。对象位置判等法用对象在内存中的位置来作为判等的唯一因素。在我们的Point2例子里,对象位置判等可能是判等的一种便捷方法,但是我们也可以用x和y的位置来判等。

你可能已经注意到Point2类覆盖了hash Code方法,但我对x实例调用的却是##方法。这是Scala的一个规约。为了与Java兼容,Scala同样使用在java.lang.Object里定义的equals和hashCode方法。但是Scala把基础数据类型也抽象成了完整的对象。编译器会在需要的时候为你自动打包和拆包基础数据类型。这些类基础数据类型(primitive-like)的对象都是scala.AnyVal的子类,而那些继承自java.lang.Objec的“标准”对象则都是scala.AnyRef 的子类。scala.AnyRef可以看作java.lang.Object的别名。因为hashCode和equals方法只在AnyRef中有定义(AnyVal里没有),所以Scala就提供了可以同时用于AnyRef和AnyVal的##和==方法。

作者注:hashCode和equals应该总是成对实现。

equals和hashCode方法应该实现为如果x == y则x.## == y.##。

我们来实现自己的判等方法,看看结果会怎样。

清单2.13 带有hashing和equals的可变Point2类

equals 的实现看上去可能有点怪,不过我会在 2.5.2 节详做解释。当前我们注意看strictEquals辅助方法直接比较x和y的值。意思是如果两个点在同一位置,就认为它们是相等的。现在我们的equals和hashCode方法采用相同标准了,也就是x和y的值。我们再次把点x和点y放入HashMap,只是这次我们准备移动点x,看看与点x绑定的标签会发生什么。

清单2.14 带有HashMap的可变Point2类

贴在点x上的标签出什么问题了?我们是在x为(1,1)的时候把它放进HashMap 的,意味着其散列值为32。然后我们把x移到了(2,2),散列值变成了64。现在我们试图查找x对应的标签时,HashMap里存放的是32,而我们却用64去找。但是为什么我们用新点z去找也找不到呢?z的散列值还是32啊。这是因为根据我们的规则,x和z 不相等。你要知道,HashMap 在插入值的时候使用散列值,但是当对象状态变化时HashMap并不会更新。这意味着我们无法用基于散列的查找来找到x对应的标签,但是我们在遍历map或者用遍历算法时还是能得到值:

如你所见,这种行为令人困扰,还会在调试的时候造成无尽的争议。因此,在实现判等的时候一般推荐确保如下的约束。

如果两个对象相等,它们的散列值应该也相等。

一个对象的散列值在对象生命周期中不应该变化。

在把对象发送到另一个JVM 时,应该用两个JVM 里都有的属性来判等。

如你所见,第二个约束意味着用来创建散列值的要素在对象生命周期里不应该变化。最后一个约束则是说,对象的散列和equals方法应该尽量用其内部状态来计算(而不依赖虚拟机里的其他因素)。再跟第一个约束结合起来,你会发现唯一满足这些要求的办法就是使用不变对象。如果对象的状态永远不变,那用状态来计算散列值或判等就是可以接受的。你可以把对象序列化到另个虚拟机,同时仍然保证其散列和判等的一致性。

你或许会奇怪为什么我要关心把对象发送到另一个 JVM?我的软件只在一个 JVM 里跑。甚至我的软件可能是在移动设备上跑的,资源是很紧张的。这种想法的问题在于把一个对象序列化到另一个 JVM 并非一定要是实时的。我们可能会把一些程序状态保存到磁盘,过会儿再读回来。这跟发送对象到另一个 JVM 其实没什么区别。尽管你或许没有通过网络传递对象,但你实际上是在通过时间传递对象,从今天这个写数据的JVM 传递到明天启动的读数据的 JVM。在这种情况下,保持一致的散列值和判等实现是非常关键的。

最后一个约束使不变性成为必要条件了。去掉这个约束的话,其实也只有以下两种较简单的办法来满足前两个约束。

在计算散列值时只使用对象的不可变状态(不用可变的状态)。

为散列计算和判等使用默认概念。

如你所见,这意味着对象里的某些状态必须是不可变的。把整个对象变成不可变实际上极大简化了整个过程。不变性不仅简化了对象判等,还简化了对数据的并发访问。

2.3.2 并发

不变性能够彻底地简化对数据的并发访问。随着多核处理器的发展,程序越来越变得并行。无论哪种计算形式,在程序里运行并发线程的需求都在增长。传统上,这意味着使用创造性的方式对多线程共享的数据进行保护。通常使用某种形式的锁来保护共享的可变数据。不变性有助于共享状态同时减少对锁的依赖。

加锁必然要承担一定的性能开销。想要读数据的线程必须在拿到锁后才能读。即使使用读写锁(read-write lock)也可能造成问题,因为写线程有可能比较慢,妨碍了读线程去读想要的数据。JVM上的JIT有做一些优化来试图避免不必要的锁。一般来说,你希望你的软件里的锁越少越好,但又必须足够多,以便能够做较多的并发。你设计代码时越能避免锁越好。我们做个案例分析——试试测量加锁对一个算法的影响,然后看我们能不能设计个新的算法,减少加锁的数量。

我们来创建个索引服务,让我们能用键值来查找特定项。这服务同时允许用户把新项加入索引中。我们预期查找值的用户数量很多,加内容的用户数量较少。这里是初始接口:

服务由两个方法构成。lookUp方法根据key的索引查找值,insert方法插入新值。这服务基本上是个键值对的映射。我们用加锁和可变HashMap来实现它。

这个类有三个成员,第一个是currentIndex,指向我们用来存放数据的可变HashMap。lookUp和insert方法都用synchronized块包起来,表明对MutableService自身做同步。你应该注意到了我们对MutableService的所有操作都加了锁。因为案例背景指出应用场景是lookUp方法比insert方法调用频繁得多,在这种场景下读写锁可能有所帮助,但我们来看看怎么能不用读写锁而用不变性来达到目的。

我们把currentIndex改成一个不可变HashMap,每次调用insert方法的时候覆盖原值。然后lookUp方法就可以不加任何锁了。我们来看以下内容。

首先要注意的是currentIndex是个指向不变变量的可变引用。每次insert操作我们都会更新引用。第二个要注意的是我们没把这个服务变成完全不可变的。我们唯一做的就是利用不可变HashMap减少了锁的使用。这个简单的改变能够带来运行时的极大提升。

我为这两个类设置了简单的微型性能测试套件。基本原理很简单:我们构建一组任务向服务写数据,另一组任务从索引读数据。然后我们把两组任务交错提交给两个线程的队列去执行。我们对整个过程的速度做计时并记录结果。下面是一些“最差场景”(worst case)的结果。

如图2.2所示,y轴表示测试的执行时间。x轴对应于提交给线程池的插入/查找任务数。你会注意到(完成同样数量的任务时)可变服务的执行时间增长快于不可变服务的执行时间。这个图明显地表现出额外的加锁对性能有严重影响。然而,有人应该会注意到这种测试的执行时间波动可能会很大。由于并行计算的不确定性,可能另一次运行产生的图上,不可变和可变服务的执行时间轨迹会几乎相同。一般来说,可变服务慢于不变服务,但是我们不该仅凭一张图或一次执行来判断性能。所以图 2.3 是另一次执行的图,你可以看到,在某一次测试里,可变服务得到上帝垂青,加锁开销极大降低。

在图2.2里你可以看到有一个测试案例执行时所有时机都配合得恰到好处,以至于可变服务在那一瞬间超过了不变服务的性能。尽管存在这种个别案例,一般情况下不变服务的性能好于可变服务。如果我们得出的结论也适用于真实世界的程序的话,就说明不变服务性能一般较优,而且也没有随机争用降速( random contention slowdown)的问题。

最重要的事是要认识到不可变对象可以安全地在多个线程之间传递而不用担心争用。能够消除锁以及锁所带来的各种潜在 bug,能极大地提高代码库(codebase)的稳定性。再加上不变性可以提高代码的可推理性,如我们在前文equals方法里所见。我们应该努力在代码库里保持不变性。

Scala 通过不变性减少了开发者在与不可变对象交互时必须得采用的保护措施,从而简化了并发程序开发。除了不变性,Scala还提供了Option类,减少了开发者在处理null时需要采用的保护措施。

2.4 用None不用null

Scala在标准库里提供了scala.Option类,鼓励大家在一般编程时尽量不要使用null。Option可以视作一个容器,里面要么有东西,要么什么都没有。Option通过两个子类来实现此含义:Some和None。Some表示容器里有且仅有一个东西,None表示空容器,有点类似List的Nil的含义。

在Java和其他允许null的语言里,null经常作为一个占位符用于返回值,表示非致命的错误,或者表示一个变量未被初始化。Scala里,你可以用Option的None子类来代表这个意思,反过来用 Option 的 Some 子类代表一个初始化了的变量或者非致命(non-fatal)的变量状态。我们来看看这两个类的用法。

清单2.15 Some和None的简单应用

不包含任何值的Option用None对象来构建,包含一个值的Option用Some工厂方法来创建。Option 提供了很多不同的方法用来把其值取出来。用得特别多的是 get 和getOrElse方法。get方法会尝试访问Option里保存的值,如果Option是空的则抛出异常。这和其他语言里访问可能为null的变量一样。 getOrElse也访问Option里存放的值,有则返回,否则返回其参数(作为默认值)。你应该尽量使用getOrElse而不是get。

Scala在Option的伴生对象里提供了工厂方法,这个方法能把Java风格的引用(null 代表空变量)转换为Option类型,使其更明确。我们快速过一下。

清单2.16 Option工厂的应用

如果输入是null,Option工厂方法会创建一个None对象,如果输入是初始化了的值,则创建一个Some对象。这使我们处理来自不信任的来源(比如另一种JVM语言)的输入,把输入包装成Option时容易许多。你可能会问,为什么我要这么做?代码里检查一下null 不是一样简单吗?好吧,Option提供了一些高级特性,使它比简单用ifnull检查要理想得多。

Option高级技巧

Option的最重要特性是可以被当作集合看待。这意味着你可以对Option使用标准的map、flatMap、foreach等方法,还可以用在for表达式里。这不仅有助于确保优美简洁的语法,而且开启了另一种不同的处理未初始化值的方法。我们来看几个常见问题,分别用null和Option来解决。第一个问题是创建对象或返回默认值。

1.创建对象或返回默认值

代码里有很多地方需要在某变量有值的时候构建某结果,变量没值的时候构建一个默认值。假设我们有个应用在执行时需要某种临时文件存储。应用设计为用户能在命令行下提供可选的参数指定一个目录来存放临时文件,如果不指定目录,那我们要返回一个合理的默认临时文件目录。我们来创建一个返回临时文件目录的方法。

清单2.17 创建一个对象或返回默认对象

getTemporaryDirectory接受Option[String]类型的参数,返回指向我们将使用的临时文件目录的File对象。我们首先对option应用map方法,在参数有值的情况下创建一个File对象。然后我们用filter方法来确保这个新创建的文件对象必须是目录,filter方法检查 option 里的值是否符合断言要求,如果不符合就转化为 None。最后我们检查Option里是否有值,如果没有则返回默认的文件路径。

这使得我们可以不需要嵌套很多(判断是否为空的)if语句或代码块就可以实施一系列的检查。有时候我们会想要基于某个参数是否存在来决定是否执行一个代码块。

2.如果变量已初始化则执行代码块

可以通过foreach方法来做到仅当Option有值时才执行某段代码块。foreach方法正如其名所示,遍历Option里的所有值。因为Option只能有零或一个值,所以其代码块要么执行,要么不执行。foreach语法和for表达式协作尤其好用。我们来看个例子。

清单2.18 如果Option有值则执行代码

如你所见,代码看上去就像一般的“迭代一个集合”的控制块。如果我们需要迭代多个变量,还是用相似的语法。我们来看个案例,假设我们使用某种Java Servlet 框架,现在我们想要对用户做验证。如果验证成功,我们要把安全令牌注入(inject)HttpSession ,以便后续的filter和servlet可以检查用户的访问权限。

清单2.19 如果多个Option都有值则执行代码

注意你可以在for表达式里嵌入条件逻辑。这样可以在代码里少用嵌套的逻辑代码块。另一个要点是所有的辅助方法都不需要使用Option类。Option用作对未初始化变量的一道优良的防火墙,你代码的其他部分可以不受污染(译者注:指不需要到处判断非空,也不需要到处使用Option,防火墙后的部分直接处理有值的情况就可以了)。在Scala里,参数类型为Option 表示参数可能是未初始化的。Scala的惯例是不要把null或未初始化的参数传给函数。

Scala的for表达式相当强大,你甚至可以用它产生值,而不只是执行代码块。当你想把一些可能为空的参数转化为某个其他结果变量的时候,这个功能就非常好用了。

3.用多个可能未初始化的变量构造另一个变量

有时候我们需要把多个可能未初始化的变量转化为一个变量以便处理。为此我们要再次使用for表达式,这次加上yield。我们来看个案例,假设我们从用户输入或者某个安全位置读取了数据库配置信息,然后尝试用这个参数创建数据库连接。因为这只是个工具函数,不需要直接面对用户,所以我们不想对获取连接失败的情况做很多处理。我们只想简单地把数据库配置参数转化为一个Option,里面放上我们的数据库连接。

清单2.20 合并多个Option

这个函数准确地达成了我们期望,虽然看上去只是在 DriverManager.getConnection 外面包了一层。那如果我们想把这种包装方法抽象化,让我们能把任意函数包装成同样对Option友好的版本要怎么做呢?来看一下我们称为lift的函数。

清单2.21 通用转换函数

lift3 方法看上去有点像我们之前那个 createConnection 方法,差别在于它接受一个函数作为唯一的参数。如你在REPL里所见,我们可以把它应用在已有的函数上,创建出Option友好的函数来。我们直接接受DriverManager.getConnection方法,然后把它提升(lift)为语义上与我们之前的createConnection方法相等的函数。这个技巧在“封装”未初始化变量时很有效。你在写大部分代码,包括工具类时,可以假定所有变量都是初始化好的,然后在需要的地方把你的函数lift成Option友好的版本。

有一点要重点注意,Option根据其包含的值来计算判等和散列值。用Scala的时候,理解判等和散列值是非常重要的,尤其是在多态的场景下。

2.5 多态场景下的判等

众所周知,为多态的面向对象系统定义合适的判等和散列方法是个特别难的过程。这是因为子类可能在整个过程中造成一些相当怪异的问题,尤其是当类型层次上有多个实体(concrete )级别的时候。一般来说,对于需要比引用判等更强的判等( 译者注:比如需要判断对象内部数据)的类,最好避免多层实体类层次。这是什么意思呢?有些时候类只需要引用判等就够了。也就是说只要两个对象不是同一个实例就判为不等。但是如果我们需要判断两个不同实例是否相等,而且又有多层实体类层次(multiple concrete hierarchies),那我们在实现判等的时候就要特别小心了。

为了理解这个问题,我们来看下如何写一个好的判等方法。为此,我们从写一个显示和渲染时间线和事件的库开始。

2.5.1 例子:时间线库

我们想构建一套时间线,或称日历构件。这个构件需要显示日期、时间、时间安排,以及每天相关的事件。这个库的基础概念叫作一个瞬时(a instantaneoustime)。

我们用 InstantaneousTime 类表示时间序列中一个特定的时间片。我们本可以用java.util.Date 类,但是我们更希望使用某种具有不变性的东西,因为我们刚刚学到了不变性使写好的equals和hashCode方法变得简单。为了简化例子,我们把时间保存为返回自1970 年1 月1 日00:00:00GMT 以来的秒数(译者注,java.util.Date 是毫秒数)。我们假定所有的其他时间都能格式化为这种形式的表示,而且时区和表现形式是正交的不同问题。我们还对应用中关于判等的使用做如下的一般假设。

如果调用equals 返回true,这是因为两个对象是同一个引用。

大部分对equals 的调用返回false。

我们实现的hashCode 足够稀疏,对于大部分判等比较,hashCode 会是不同的。

计算散列值比做一次深度判等比较的效率高。

引用判等比做一次深度判等比较的效率高。

上述假设是大部分判等实现的标准假设。但对你的应用来说不一定始终正确。我们现在初步实现这个类和简单的equals、hashCode方法,看下是什么样子。

清单2.22 简单的InstantaneousTime类

这个类只有一个成员,repr,是个整数,表示自1970 年1月1日00:00:00 GMT 以来的秒数。因为repr是这个类里唯一的数据值,并且它具有不变性,equals和hashCode 方法就基于这个值来实现。在JVM里实现equals时,一般来说在做深度判等前先判断引用是否相等的性能更高。但是在这个例子里就没必要这么做了。对于有一定复杂度的类来说,这么做(先判断引用相等)能够极大地提高性能,然而这个类太简单,真没必要这么做。设计好的 equals 方法的另一个常用范式是(在深度判等之前)用 hashCode 做个早期判断。在散列值足够稀疏且易于计算的情况下,这是一个好主意。跟引用判等一样,在当前这个例子里不是很需要这么做,但对于一个足够复杂的类来说,性能会高很多。

这个类告诉我们两个道理:①好的判等方法很重要。②你应该经常挑战代码里的假定条件。在这个例子里,按照“最佳实践”实现的判等方法,尽管对于足够复杂的类非常有用,但对于我们这个简单的类就几乎没上面好处。

注意:在给自己的类实现判等方法时,确认一下标准的判断实现方式中的一些假设对你的类是否适用。

我们的equals实现还有一个瑕疵,那就是多态。

2.5.2 多态判等实现

一般来说,最好避免在需要深度判等的情况下使用多态。Scala 语言自身就出于这个原因不再支持 case class 的子类继承。然而,还是有些时候这样做是有用甚至是必要的。要做到这一点,我们需要确保正确地实现了判等比较,把多态放在脑子里,并且在方案中利用多态。

我们来实现一个Instantaneous Time的子类,这个子类比父类多保存了标签(label)。我们在时间线上保存时间的时候使用这个类,所以我们就叫它 Event。我们假定同一天的事件被散列到同一个桶里,因此具有相同的散列值。但是判等则还要检查事件的名字是否相等。我们快速地实现一个。

清单2.23 InstantaneousTime类的Event 子类

我们抛弃了之前代码里的 hashCode 早期检测,因为在我们这个特定的案例里,检测repr的值性能是一样的高。你会注意到的另一件事是我们修改了模式匹配,使得只有两个Event对象才能做判等。我们在REPL里试用一下。

清单2.24 使用Event和InstantaneousTime

发生什么事了?旧的类使用旧的判等方法,因此没检查新的name字段,我们需要修改基类里最初的判等实现,以便考虑到子类可能希望修改判等的实现方法。在 Scala 里,有个scala.Equals特质能帮我们修复这个问题。Equals特质定义了一个canEqual方法,可以和标准的equals方法串联起来用。通过让equals方法的other参数有机会直接造成判断失败,canEqual方法使子类可以跳出(opt-out)其父类的判等实现。为此我们只需要在我们的子类里覆盖canEqual方法,注入我们想要的任何判断标准。

在考虑到多态的情况下,我们用这两个方法来修改我们的类。

清单2.25 使用scala.Equals

我们做的第一件事是在InstantaneousTime里实现canEqual,当other对象也是一个InstantaneousTime时返回true。然后我们在equals实现里考虑到other对象的canEqual 结果。最后,Event类里覆盖canEqual方法,使Event只能和其他Event做判等。

作者注:在覆盖父类的判等方法时,同时覆盖canEqual方法。

canEqual方法是个控制杆,允许子类跳出父类的判等实现。这样子类就可以安全地覆盖父类的equals方法,而避免父类和子类的判等方法对相同的两个对象给出不同的结果。

我们来看下之前的REPL会话,看看新的equals方法是否有所改善。

清单2.26 使用新的equals 和canEquals 方法

我们成功地定义了恰当的判等方法。我们现在可以写出一般情况下通用的equals方法,也可以正确处理多态场景了。

2.6 总结

本章中我们了解了Scala编程时的第一个关键组成部分。利用REPL做快速原型是每个成功的Scala开发者必须掌握的关键技术之一。面向表达式编程和不可变性都有助于简化程序和提高代码的可推理性。Option也有助于可推理性,因为它明确声明了是否接受空值。另外,在多态的场景下实现好的判等可能不容易。以上这些实践可以帮助我们成功踏出Scala开发的第一步。要想后面的路也走得顺利,我们就必须来看一下编码规范,以及如何避免掉进Scala解析器的坑。

相关图书

Clojure Web开发实战
Clojure Web开发实战
Haskell并行与并发编程
Haskell并行与并发编程
Haskell函数式编程入门
Haskell函数式编程入门
Haskell趣学指南
Haskell趣学指南
Clojure编程乐趣
Clojure编程乐趣
Clojure程序设计
Clojure程序设计

相关文章

相关课程