4. 控制结构
正如名字所言,控制结构给程序员提供了一种控制程序执行流的方式。这是一个程序语言进行决策和轮询任务的基本功能。回到学习Scala的2010年,我曾认为控制结构如if/then语句,以及for和while循环,是一个相对无聊的程序功能,但那只是因为我并不知道还有其他方式。这些天我知道了控制结构一个程序语言定义性的功能(defining feature)。
Scala的控制结构包括:
for循环和for表达式
if/then/else语句和if表达式
match 表达式 (模式匹配)
try/catch/finally块
while循环
接下来我会简单介绍一下,之后会在具体的小节中展示更多的细节来描述如何使用这些功能。
for循环和for表达式
在大多数的基础用法中, for循环提供了一个针对集合的迭代操作,如下:
for i <- List(1, 2, 3) do println(i)但是这仅仅只是一个基础用法。for循环也可以包含守卫 —— 嵌入if语句:
for
i <- 1 to 10
if i > 3
if i < 6
do
println(i)通过使用yield关键字,for循环也变成了 for 表达式 —— 产生结果的循环:
在循环运行后,listOfInts变成了一个Vector(40, 50)。这个在循环中的守卫语句过滤掉了除了4和5的所有值,并将这些值在yield代码块中乘以10。
关于for循环和表达式的更多细节将在本章的初始章节中涉及。
if/then/else-if表达式
正如for循环和表达式给你遍历集合的能力,if/then/else 表达式则提供了分支决策的能力。在Scala 3中,首选的语法已经改变了,现在看起来是这样的:
正如两个例子中的展示,一个if表达式就是一个带返回值的表达式(表达式的讨论参阅4.5小节)。
match表达式和模式匹配
接下来,match表达式和模式匹配是Scala的基本功能,本章节的大部分内容将展示这些功能。正如if表达式,match表达式也有返回值,所以可以把他们用作方法体。
比如下面的一个例子,这个方法相似于Perl语言版的 true 或 false 判断:
这段代码中, 如果isTrue收到了0或者空字串,它将返回false,否者true。本章花了十个小节来描述match表达式的功能细节。
try/catch/finally代码块
Scala的try/catch/finally代码块类似于Java,但语法上有一点不同,catch代码块是由match表达式构成的:
正如if和match,try也是一个会返回值的表达式,你可以如下进行String到Int的转换:
这些例子展现了toInt如何运行:
关于 try/catch 代码块的更多信息请参阅4.16小节。
while循环
当来到while循环,你会发现它是真的很少有在Scala中用到。这是因为while循环几乎都是用于副作用,比如更改可变参数和用println打印,并且这些都是可以通过for循环和foreach方法对集合进行操作的。也就是说,如果你需要用到它,大体如下所示:
4.1小节将简要介绍 while 循环。
最后,通过数个Scala功能的合并,你可以创建自己的控制结构,这些将在4.17小节进行讨论。
控制结构是编程语言的界定特征
在2020年末,我足够幸运的合写了在Scala官方文档网站( https://docs.scala-lang.org/ )的Scala 3 Book,包括如下三章:
给Java开发者的Scala ( https://oreil.ly/Psbc4 )
给JS开发者的Scala ( https://oreil.ly/cDyzW )
给Python开发者的Scala( https://oreil.ly/6zIAX )
当我之前说控制结构作为“编程语言的界定特征”时,我的意思之一是,在我写完这些章节后,我开始意识到该章节所述功能的威力,以及和其他语言相比Scala的一致性是如何强大。这样的一致性是保持Scala能被愉快使用的功能之一。
4.1 在数据结构上的for循环
问题
你想要用一个传统for循环的方式来遍历一个集合的所有元素。
解决方案
有很多方法可以遍历 Scala 集合,包括 for 循环、while 循环、以及如foreach、map、flatMap 等集合方法。 该解决方案重点主要在for循环上。
给定一个简单的列表:
你可以像这样遍历这些元素并打印它们:
同样的方法适用于所有序列,包括 List、Seq、Vector、Array、ArrayBuffer等。
当你的算法需要多行时,使用相同的 for 循环语法,并将你的处理程序放在大括号内的一个块中:
for循环计数器
如果你需要访问 for 循环内的计数器,请使用以下方法之一。
首先,你可以使用这样的计数器访问序列中的元素:
该循环产生此输出:
你很少需要依靠索引访问序列元素,然而当你需要时,这是一种可能的方法。Scala集合还提供了一个 zipWithIndex 方法,可用于创建循环计数器:
它的输出如下:
生成器
在相关记录中,以下示例展示了如何使用 Range 执行循环三次:
for循环中的 1 to 3 代码部分创建了一个 Range,如REPL中所示:
像这样使用一个 Range 对象,被称为使用一个 生成器(generator)。4.2小节展示了如何使用此技术创建带有多个计数器的for循环。
Map上的循环
当迭代 Map 中的键和值时,我发现这样的for循环最简洁易读的:
REPL展示了的输出是:
讨论
因为我已经切换到函数式编程风格,所以我已经好几年没有使用while循环了,但是REPL展示了它是如何工作的:
while 循环通常用于副作用,例如,像我这样更新可变变量以及向外界输出。随着我的代码越来越接近纯函数式编程 —— 一种没有可变状态的方式 —— 我不再需要 while。
也就是说,当你以面向对象的编程风格进行编程时,仍然会经常使用while循环,该示例展示了它们的语法。 也可以像这样把一个while循环写在多行上:
集合方法,比如foreach
在某些方面,Scala 让我想起了 Perl 的口号,“有不止一种方法可以做它”,并且集合上的迭代提供了一些很好的例子。随着集合上可用的大量方法,这重点声明的是一个 for 循环甚至可能不是解决特定问题的最佳方法;foreach、map、flatMap、collect、reduce 等方法经常可以用来解决你的问题而不需要显式的 for 循环。
例如,当你使用集合时,你还可以通过调用集合上的foreach方法遍历每个集合元素:
当你有一个算法要在集合中的每个元素上运行时,只需将这个匿名函数传递给foreach:
与for循环一样,如果你的算法需要多行,请在一个代码块中执行相应的程序:
另见
有关如何使用zipWithIndex的更多示例,请参阅13.4小节“使用zipWithIndex或zip以创建循环计数器”。
有关如何迭代Map中的元素的更多示例,请参阅14.9小节“遍历Map”。
for 循环的工作原理非常有趣,了解它可以帮助你进步。我在这些文章中详细介绍了这一点:
“How to Make a Custom Sequence Work as a for Loop Generator” ( https://oreil.ly/UFVQ2 )
“How to Enable Filtering in a for Expression” ( https://oreil.ly/3anl9 )
“How to Enable the Use of Multiple Generators in a for Expression”( https://oreil.ly/RZGNP )
4.2 使用带有多个计数器的for循环
问题
你想创建一个具有多个计数器的循环,例如在迭代多维数组时。
解决方案
你可以创建如下带有两个计数器的for循环:
执行如下:i 被设置为 1 时,遍历 j 中的元素,然后将 i 设置为 2 并重复该过程。
使用这种方法可以很好地处理这些简单示例,但是当你的代码变复杂时,这是首选样式:
这种方法在循环多维数组时很有用。假设你创建并填充一个像这样的小型二维数组:
你可以像这样打印每一个数组元素:
讨论
如15.2小节“创建Range”所示,1 to 5 的语法创建了一个Range:
Range适用于许多目的,并且在for循环中使用的 <- 符号创建的Range称为生成器。同理,你也可以轻松在一个循环中使用多个生成器。
另见
关于格式化控制结构的Scala风格指南( https://oreil.ly/3jB28 )
4.3 使用带有嵌入式if语句的for循环(守卫语句)
问题
你希望在for循环中添加一个或多个条件子句,通常是为了过滤掉集合中的某些元素,同时处理其他元素。
解决方案
在生成器之后添加一个或多个if语句,如下所示:
这些if语句被称为filter,filter表达式或守卫语句,而你可以根据手头的问题使用尽可能需要的守卫语句。这个循环显示了一个打印数字4的生硬方式:
讨论
使用旧样式的if表达式编写for循环仍然是可以的。例如,给定这段代码:
理论上,你可以像这样用这种风格编写一个 for 循环,这让人想起C和Java:
然而,一旦你熟悉了Scala的for循环语法,我想你会发现它使代码更具可读性,因为它将循环和过滤的关注点从业务逻辑中分开:
请注意,由于守卫语句通常旨在过滤集合,因此你可能希望使用可用于集合的众多过滤方法中的一种(filter、take、drop等),而不是for循环,具体取决于你的需求。具体例子请参阅第11章。
4.4 通过for/yield从现有集合创建新集合
问题
你想从一个现有的集合通过应用一个算法(和可能的一个或多个守卫语句)到每个元素来创建一个新的集合。
解决方案
使用一个带有for循环的yield语句来从一个现有的集合中创建一个新的集合。例如,给定一个小写的字符串数组:
你可以通过将带有for循环的yield和一个简单的算法结合起来新创建一个首字母大写的字符串数组:
使用带有yield语句的for循环称为for 推断(for-comprehension)。
如果你的算法需要多行代码,请在块中通过yield关键字,并手动指定结果变量的类型来实现,或者不用:
两种方法yield可以得到相同的结果:
for推断(也称为for表达式)的两个部分都可以变得比较复杂。下面是一个更大的例子:
for推断产生以下结果:
一个for推断甚至可以是完整方法体:
讨论
如果你不熟悉将yield与for循环一起使用,则可以这样考虑循环:
当它开始运行时,for/yield循环立即创建一个新的与输入集合具有相同类型的空集合。例如,如果输入类型是Vector,输出类型也将是Vector。你可以想象这个新集合就像一个空桶。
在for循环的每次迭代中,可能会从输入集合创建一个新的输出元素。输出元素被创建时,它就会被放在桶里。
当循环结束运行时,返回桶的全部内容。
这值是一种简化的描述,但我发现它在解释过程时很有帮助。
注意,写一个不带守卫的for表达式就像在一个集合上调用map方法一样。
例如,下面的for推断将fruits中的所有字符串集合转为大写:
在集合上调用map方法做同样的事情:
当我刚开始学习Scala时,我所有的代码都是用 for/yield 表达式写的,直到有一天我意识到,使用不带守卫的 for/yield ,就等于使用 map方法。
另见
13.5小节“使用map将一个集合转换为另一个集合”,更详细了展示了for推断和map方法之间的比较。
4.5 像三元运算符一样使用if结构
问题
你熟悉如下Java中特殊的 三元运算符(ternary operator或称三目运算符 ) 语法:
并且你想知道如何在Scala中使用等价的表达方式。
解决方案
这是一个有点棘手的问题,因为与 Java 不同的是,在 Scala 中没有特殊的三元操作符;所以只需使用 if/else/then 表达式:
因为 if 表达式返回一个值,所以你可以将其嵌入到 print 语句中:
你还可以在另一个表达式中使用它,例如 hashCode 方法的这一部分:
if/else 表达式返回值这一事实也让你可以编写简洁的方法:
讨论
“等式、关系和条件运算符”的Java文档页面( https://oreil.ly/rUDNP )指出,Java条件运算符 ?: “被称为三元运算符,因为它使用三个操作数。”
Java 在这里需要单独的语法,因为Java的if/else构造是一个语句;它没有返回值,仅用于副作用,例如更新可变字段。相反,因为Scala的if/else/then确实是一个表达式,并不需要特殊的操作符。参阅 24.3小节“编写表达式(而不是语句)”,以获取更多的语句和表达式的细节。
Arity(操作符词量) -- TODO 鸽子栏
三元 这个词与函数的操作符数量( Arity )有关。维基百科的“Arity页面”( https://oreil.ly/CJtbV )指出,“在逻辑、数学和计算机科学中,函数或操作的操作符数量是函数采用的参数或操作数的数量。” 一元运算符采用一个操作数,二元运算符采用两个操作数,三元运算符采用三个操作数。
4.6 像switch语句一样使用match表达式
问题
你有一种情况,你想创建一个简单的基于Java整数的switch语句,例如匹配一周中的天数、一年中的月数,以及一个整数映射到某个结果的其他场景。
解决方案
需要使用Scala的match表达式,如简单的基于整数的switch语句,请使用以下方法:
该示例展示了如何基于match执行副作用操作(println)。一个更加函数式的方法是从match表达式返回一个值:
@switch 注解
在编写这样的简单match表达式时,建议使用 @switch 注解,如下所示。如果switch无法编译为tableswitch或lookupswitch,此注释会在编译时提供警告。将match表达式编译为tableswitch或lookupswitch会更好地提高性能,因为它会生成分支表而不是决策树。当给表达式一个值时,它可以直接跳转到结果,而不是通过决策树。
Scala @switch 注释文档( https://oreil.ly/rrNBP )指出:
如果该注解存在,编译器将验证match是否已编译为tableswitch或lookupswitch,如果它编译为一系列条件表达式,则会发出错误。
可以通过一个简单的例子来展示 @switch 注解的效果。首先,将以下代码放入名为SwitchDemo.scala的文件中:
然后像往常一样编译代码:
编译此类不会产生警告并创建SwitchDemo.class输出文件。接下来,使用以下 javap 命令反汇编该文件:
这个命令行的输出给出了类似如下的tableswitch:
这表明 Scala 能够将match表达式优化为 tableswitch。(这是一件好事。)
接下来,对代码稍作改动,将整数文字 1 替换为一个值:
同样,使用scalac编译代码,但你会立即看到一条警告消息:
这条警告信息意味着既不能为match生成一个tableswitch,也不能为其生成一个lookupswitch。你可以通过运行 javap 的命令在生成的SwitchDemo.class文件上来确认这一点。当你查看该输出时,会看到前面示例中显示的tableswitch现在已消失。
Joshua Suereth在他的书Scala in Depth(Manning)中指出,在Scala中使用tableswitch优化必须满足以下条件:
匹配的值必须是已知的整数。
匹配的表达式必须是“简单的”。它不能包含任何类型检查、if 语句或提取器。
表达式的值必须在编译时可用。
应该有两个以上的 case 语句。
讨论
解决方案中的示例展示了两种处理默认“catch all”情况的方法。首先,如果你不关心默认匹配的值,你可以使用 _ 通配符来捕获它:
相反,如果你对默认匹配的内容感兴趣,请为其分配一个变量名称。然后,你可以在表达式的右侧使用该变量:
使用像default这样的名称通常是最有意义的,但你可以为变量使用任何合法名称:
重要的是要知道,如果你不处理默认情况,你会得到MatchError。给定这个match表达式:
如果i是0或1以外的值,则表达式将引发MatchError:
因此,除非你有意编写偏函数,否则你将需要处理默认情况。
你真的需要match表达式吗?
请注意,对于这样的示例,你可能不需要match表达式。例如,任何时候你只是将一个值映射到另一个值,最好使用Map:
另见
有关JVM开关如何工作的更多信息,请参阅编译开关的JVM规范( https://oreil.ly/oUcwX )。
关于lookupswitch 和tableswitch 之间的区别,这个Stack Overflow页面( https://oreil.ly/JvE3P )指出,“区别在于lookupswitch 使用带有键和标签的表,而tableswitch 只使用带有标签的表。” 同样,有关更多详细信息,请参阅Java 虚拟机 (JVM) 规范的“Compiling Switches”部分( https://oreil.ly/oUcwX )。
有关偏函数的更多信息,请参阅10.7小节“创建偏函数”。
4.7 用一个case语句匹配多个条件
问题
你需要在match的多个匹配条件中执行相同的业务逻辑,而不是为每个case重复你的业务逻辑,你希望为匹配条件使用同一个业务逻辑代码。
解决方案
将执行相同业务逻辑的匹配条件放在一行,用 | 分隔(管道)字符:
相同的语法适用于字符串和其他类型。下面是一个基于字符串匹配的例子:
下面的例子展示了如何在每个case语句中匹配多个对象:
如上所示,为每个case语句定义多个可能匹配项的能力可以简化你的代码。
另见
相关方法请参阅4.12小节。
4.8 将match表达式的结果分配给变量
问题
你想要从match表达式返回一个值并将其分配给变量,或者使用match表达式作为方法的主体。
解决方案
要将match表达式的结果分配给变量,请在表达式之前创建变量并赋值,如下面例子中的变量evenOrOdd:
这种方法通常用于创建短方法或函数。例如,下面的方法实现了Perl对true和false的定义:
讨论
你可能听说Scala是一种面向表达式的编程(expression-oriented programming(EOP))语言。EOP意味着每个构造都是一个表达式,产生一个值,并且没有副作用。与其他语言不同,在Scala中,每个构造(如if、match、for和try)都会返回一个值。有关更多详细信息,请参阅24.3小节。
4.9 在match表达式中访问默认情况的值
问题
你希望在使用match表达式时访问默认“catch all”的case表达式的值,但当前在使用 _ 通配符语法匹配时无法访问该值。
解决方案
不要使用 _ 通配符,而是为默认case分配一个变量名:
通过给默认匹配项指定一个变量名,你可以在表达式右侧访问该变量。
讨论
这种技巧的关键是使用一个变量名作为默认匹配,而不是通常的 _ 通配符。你分配的名称可以是任何合法的变量名称,因此你可以将其命名为其他名称,而不是default名称,例如waht:
提供默认匹配很重要。不这样做可能会导致MatchError:
有关MatchError的更多详细信息,请参阅4.6小节的讨论。
4.10 在match表达式中使用模式匹配
问题
你需要在match表达式中匹配一个或多个模式,该模式可以是常量模式、变量模式、构造函数模式、序列模式、元组模式或类型化模式。
解决方案
为要匹配的每个模式定义一个case语句。 以下方法显示了你可以在match表达式中使用的许多不同类型的模式的示例:
上述方法中的大match表达式显示了Programming in Scala一书中描述的不同类别的模式,包括常量模式、序列模式、元组模式、构造函数模式和类型化模式。
以下代码展示了 match 表达式中的所有情况,每个表达式的输出显示在注释中。 注意println方法是在导入时重命名使得例子更简洁:
请注意,在 match 表达式中,List和Map表达式是这样编写的:
也可以写成如下的形式:
我更喜欢下划线语法,因为它清楚地表明我不关心List或Map中存储的内容。实际上,有时我可能对存储在List或Map中的内容感兴趣,但由于JVM中的类型擦除,这成为一个难题。
类型擦除 -- TODO 耗子栏
当我第一次写这个例子的时候,我写的List表达式如下:
如果你熟悉Java平台上的类型擦除,你可能知道这是行不通的。Scala编译器通过以下警告消息让你了解此问题:
如果你不熟悉类型擦除,我在本章节的查看更多部分中包含了一个链接,该链接的网页描述了它是如何在JVM上工作的。
讨论
通常,当使用这种技术时,你的方法将期望一个从基类或特质继承的实例,然后你的case语句将引用该基类型的子类型。这是在测试方法中推断出来的,其中每个Scala类型都是 Matchable 的子类型。下面的代码展示了一个更明显的例子。
在我的Blue Parrot 应用程序( https://alvinalexander.com/blueparrot )中,它要么播放声音文件,要么以随机的时间间隔“说出”它给出的文本,如下方法所示:
makeRandomNoise方法携带一个RandomThing类型的参数,且match表达式处理它的两个子类型RandomFile和RandomString的方法。
模式
解决方案中的大match表达式显示了在 Programming in Scala 一书中定义的各种模式(由Scala语言的创建者Martin Odersky合著)。这些模式包括:
常量模式
变量模式
构造函数模式
序列模式
元组模式
类型化模式
变量绑定模式
这些模式将在以下段落中进行简要描述。
常量模式
一个常量模式只能匹配它自己。任何文字都可以用作常量。如果将 0 指定为常量,则只会匹配为 0 的 Int 值。例如:
变量模式
这并没有在解决方案的大match示例中演示,但匹配任何对象的一个变量模式,就像 _ 通配符一样。可以被Scala绑定到任何对象,这使你可以在case语句的右侧使用变量。例如,在match表达式的末尾,你可以像这样使用 _ 通配符来捕获其他任何内容:
但是使用变量模式,你可以改为:
更多信息请参阅4.9小节。
构造函数模式
构造函数模式允许你在 case 语句中匹配构造函数。如示例所示,你可以根据需要指定常量或变量模式:
序列模式
你可以匹配List、Array、Vector等序列。使用 _ 字符代表序列中的一个元素,使用 _* 代表零个或多个元素,如示例中所示:
元组模式
如示例中所示,你可以匹配元组模式并访问元组中每个元素的值。如果你对值不感兴趣,也可以使用 _ 通配符:
类型化模式
在以下示例中,str: String是类型化模式,str是模式变量:
如示例所示,你可以在声明后访问表达式的模式变量。
变量绑定模式
有时你可能希望将变量添加到模式中。你可以使用以下通用语法来执行此操作:
这称为变量绑定模式。使用时,将match表达式的输入变量与模式进行比较,如果匹配,则将输入变量绑定到variableName。
通过展示它解决的问题可以最好地体现它的有用性。假设你有前面演示的 List 模式:
正如展示的那样,这让你可以匹配第一个元素为1的List,但到目前为止,还没有在表达式的右侧访问List。访问列表时,你知道可以这样做:
所以看起来你应该用序列模式试试这个:
不幸的是,这会失败并出现以下编译错误:
这个问题的解决方案是在序列模式中添加一个变量绑定模式:
此代码能够完成编译并按预期工作,你也可以在语句的右侧访问该List。
以下代码展示了该示例以及该处理方式的高效:
在match表达式内的两个List示例中,注释掉的代码行是无法编译的,但第二行展示了如何匹配所需的List对象,然后将该列表绑定到变量x。当这行代码匹配像 List(1,2,3) 这样的列表时,它会产生输出 List(1, 2, 3),如第一个println语句的输出所示。
第一个Some示例表示你可以将Some按所示方式匹配,但你无法在右侧的表达式访问其信息。第二个示例展示了如何访问Some中的值,第三个示例更进一步,让你可以访问Some对象本身。当它与第二个println调用匹配时,它会打印Some(foo),表明你现在可以访问Some对象。
最后,此方法用于匹配姓氏为 Doe 的 Person。此语法允许你将模式匹配的结果分配给变量p,然后在表达式的右侧访问该变量。
在match表达式中使用Some和None
为了完善这些示例,你经常将Some和None与match表达式一起使用。
例如,当你尝试使用类似toIntOption的方法从字符串创建数字时,你可以在match表达式中处理结果:
在match表达式中,你只需指定Some和None情况,如上所示来处理成功和失败条件。参阅24.6小节“使用 Scala 的错误处理类型(Option、Try 和 Either)”,以了解更多使用Option、Some和None的示例。
另见
Stack Overflow( http://bit.ly/15odxST )上的讨论,在使用match表达式时绕过类型擦除
My Blue Parrot应用程序( https://alvinalexander.com/blueparrot )
类型擦除文档( http://bit.ly/139WrFj )
4.11 在match表达式中使用枚举和样例类
问题
你想在match表达式中匹配枚举、样例类或样例对象。
解决方案
以下示例展示了如何使用模式匹配枚举的不同方式,具体取决于你在每个case语句右侧需要哪些信息。首先,这是一个名为Animal的枚举,它具有三个实例Dog、Cat和Woodpecker:
给定该枚举,下面getInfo方法展示了在match达式中匹配枚举类型的不同方式:
下面的例子展示了当给定Dog、Cat和Woodpecker时getInfo如何工作:
在getInfo中,如果Dog类匹配,则提取其名称并用于在表达式右侧创建字符串。为了展示提取名称时使用的变量名称可以是任何合法的变量名称,我使用名称moniker。
匹配Cat时,我想忽略名称,因此我使用上述的语法来匹配任何Cat实例。因为Woodpecker不是使用参数创建的,所以它也匹配,如上所示。
讨论
在 Scala 2 中,sealed traits与case class和case object一起使用,以实现与枚举相同的效果:
如6.12小节“如何使用枚举创建命名值集”所述,枚举是(a)定义sealed class或trait以及(b)定义为类的伴随对象成员的值的快捷方式。这两种方法都可以在getInfo的match表达式中使用,因为case class有一个内置的unapply方法,可以让它们在match表达式中工作。我在7.8小节“使用unapply实现模式匹配”中描述了它是如何工作的。
4.12 将if表达式(守卫语句)添加到case语句中
问题
你希望将特定的逻辑添加到match表达式中的case语句,例如允许一定范围的数字或匹配模式,但前提是该模式与某些附加条件匹配。
解决方案
在你的case语句中添加一个if守卫语句。用它来匹配一系列数字:
用它来匹配对象的不同值:
只要你的类有一个unapply方法,你就可以在你的if守卫语句中引用类字段。例如,因为一个样例类有一个自动生成的unapply方法,给定这个Stock类和实例:
你可以在类变量中使用模式匹配和守卫语句条件:
你还可以从case class和已正确实现unapply方法的类中提取字段,并在你的守卫语句条件中使用这些字段。例如:
如果将Person定义为case class,则可以正常运行:
或者正确实现了unapply方法的Person类:
有关如何编写unapply方法的更多详细信息,请参阅7.8小节“使用unapply实现模式匹配”。
讨论
每当你想在case语句的左侧(即 => 符号之前)添加条件推断时,都可以使用这样的if表达式。
请注意,所有这些示例都可以通过将if推断放在表达式的右侧来编写,如下所示:
但是,在许多情况下,通过将if守卫语句直接与case语句相结合,你的代码将更简单、更易于阅读;它有助于将守卫与后来的业务逻辑分开。
另请注意,这个Person示例有点刻意,因为Scala的模式匹配功能让你可以像这样编写案例:
在这种情况下,当Person更复杂并且你需要做的不仅仅是匹配其参数时,确实需要一个守卫语句。
此外,如4.10小节中所示,与其使用如下的解决方案:
还有另一种可能的解决方案是使用变量绑定模式:
这段代码可以理解为:“如果match表达式的值 x 是 2 或 3,则将该值赋给变量x,然后使用println打印 x。”
4.13 使用match表达式代替isInstanceOf
问题
你想编写一段代码来匹配一种类型或多种不同的类型。
解决方案
你可以使用isInstanceOf方法来测试对象的类型:
然而,“Scala方式”更倾向于match表达式这种工作方式,因为使用match表达式通常比isInstanceOf更强大和更方便。
例如,在一个基本用例中,你可能会收到一个未知类型的对象,并希望确定该对象是否是Person的实例。这段代码展示了如何编写一个match表达式,如果类型是Person,则返回true,否则返回false:
更常见的场景是你将有一个这样的模型:
然后你会想写一个方法来计算Shape的面积。此问题的一种解决方案是使用模式匹配来编写area方法:
这是一种常见的用法,其中area接受一个参数,其类型是你在match中解构类型的直接父级。
请注意,如果Circle和Square采用额外的构造函数参数,并且你只需要访问它们的半径和长度,则完整的解决方案如下所示:
如match表达式中的case语句所示,只需忽略不需要的参数,用 _ 字符指代它们。
讨论
如上所示,match表达式允许你匹配多种类型,因此使用它来替换isInstanceOf方法只是对match/case语法和Scala应用程序中使用的一般模式匹配方法的自然使用。
对于最基本的用例,isInstanceOf方法可以是一种更简单的方法来确定一个对象是否匹配一个类型:
但是,对于比这更复杂的场景,match表达式比长if/then/else if语句更具可读性。
面向对象中的isInstanceOf -- TODO 方块中
在面向对象编程中,使用isInstanceOf可能表明你没有正确使用继承。例如,有人可能写过这样的代码:
如果由于某种原因你发现自己正在编写这样的isInstanceOf代码,这表明你可能做错了:
相反,OOP代码看起来像这样:
或者这样:
如果你写这样的代码来接收一个Animal,代码不应该关心是否你收到的动物是Cat、Dog或Ostrich,如果你立即强制转换该实例到Ostrich,你知道你可以调用它的任何其他方法。因此,在OOP中,你应该很少需要使用isInstanceOf测试实例。相反,在函数式编程代码中,可以一直使用match表达式的模式匹配来处理类型。
另见
4.10小节展示了更多的 match 的技术。
4.14 在match表达式中使用列表
问题
你知道List数据结构与其他顺序数据结构有点不同:它由cons单元构建,并以Nil元素结束。你想要结合match表达式时来利用这个优势, 例如在编写递归时功能。
解决方案
你可以创建一个包含整数1、2和3的列表,如下所示:
或像这样:
如第二个示例所示,List以Nil元素结尾,你可以在编写match表达式利用这个优势来处理列表,尤其是当编写递归算法。例如,在下面的listToString方法中,如果当前元素不是Nil,该方法被递归调用剩余的List,但如果当前元素为Nil,则停止递归调用并返回返回空字符串:
REPL展示了此方法的是如何工作的:
在处理其他类型和不同算法的列表时,可以使用相同的方法。例如,虽然你可以只编写List(1,2,3).sum,但此示例展示了如何使用匹配和递归编写你自己的sum方法:
同样,这是一个乘积算法:
REPL展示了这些方法是如何工作的:
不要忘记reduce和fold操作 -- TODO 耗子栏
虽然递归很棒,但Scala在集合类上的各种reduce和fold方法是为了让你在遍历集合的同时应用算法,且它们通常不需要递归。
例如,你可以使用以下两种形式中的任何一种来编写求和算法:
有关详细信息,请参阅13.10小节“使用reduce和fold方法遍历集合”。
讨论
如上所示,递归是一种方法调用自身以解决问题的技术。在函数式编程中——所有变量都是不可变的——递归提供了一种迭代List中的元素以解决问题的方法,例如计算List中所有元素的总和或乘积。特别是使用List类的一个好处是List以Nil元素结尾,因此你的递归算法通常具有以下模式:
函数式编程中的变量 -- TODO 耗子栏
在FP中,我们使用术语变量,但当我们只使用不可变变量时,这个词似乎没有什么意义,即我们有一个不能变化的变量。
这里发生的事情是,我们真正的意思是代数意义上的“变量”,而不是计算机编程意义上的“变量”。例如,在代数中,当我们写下这个代数方程时,我们说a、b和c是变量:
但是,一旦它们被分配,它们就不能改变。术语变量在函数式编程中具有相同的含义。
另见
我最初发现递归是一个不必要的难以掌握的话题,所以我写了很多关于它的博客文章:
“Scala Recursion Examples (Recursive Programming)” ( https://oreil.ly/5LASz )
“Recursive: How Recursive Function Calls Work”( https://oreil.ly/vIfgS )
“Tail-Recursive Algorithms in Scala”( https://oreil.ly/wJat4 )
“Recursion: Visualizing the Recursive sum Function”( https://oreil.ly/wJat4 )
在“Recursion: Thinking Recursively”( https://oreil.ly/0grXf )中,我写了特征(identity)元素,包括 0 是和运算的特征元素,1 是乘运算的特征元素,而""(一个空字符串)是处理字符串的特征元素。
4.15 用try/catch匹配一个或多个异常
问题
你想在try/catch块中捕获一个或多个异常。
解决方案
Scala的try/catch/finally语法类似于Java,但它在catch块中使用了match表达式:
当你需要捕获和处理多个异常时,只需将异常类型添加为不同的case语句即可:
如果你愿意,也可以像这样编写代码:
讨论
综上所述,Scala的case语法用于匹配不同的可能出现的异常。如果你不关心抛出哪些特定的异常,并且想捕获所有异常并对其进行处理(例如记录它们),请使用以下语法:
由于某种原因,如果你不关心异常的值,你也可以像这样捕获它们并忽略它们:
基于try/catch的方法
如本章的介绍所示,try/catch/finally块可以返回一个值,因此可以用作方法的主体。以下方法返回一个 Option[String]。如果找到文件,它返回一个包含字符串的 Some,如果读取文件有问题,则返回None:
这展示了一种从try表达式返回值的方式。
这些天来,我很少编写抛出异常的方法,但是像Java一样,你可以从catch子句中抛出异常。然而,因为Scala没有检查异常,你不需要指定一个方法抛出的异常。如下例子所示,该方法不包含任何注解:
这实际上是一种非常危险的方法 —— 不要写这样的代码!
要声明方法抛出异常,请将 @throws 注解添加到方法定义中:
虽然最后一种方法比前一种方法好,但两者都不是首选的方法。 “Scala 方式”是从不抛出异常。相反,你应该使用Option,如前所示,或者当你想要返回有关失败的信息时,使用Try/Success/Failure或Either/Right/Left类。下面的例子展示了如何使用Try:
每当涉及异常消息时,我总是更喜欢使用Try或Either而不是Option,因为它们使你可以访问Failure或Left中的消息,其中Option仅返回None。
捕捉一切的简洁方法
捕获所有异常的另一种简洁方法是使用scala.util.control.Exception对象的allCatch方法。以下示例展示了如何使用 allCatch,先展示成功案例,再展示失败案例。每个表达式的输出放在每一行的注释之后:
另见
有关声明方法可能抛出异常的更多示例,请参阅8.7小节“声明方法可以抛出异常”。
有关使用 Option/Some/None 和 Try/Success/Failure 的更多信息,请参阅24.6小节“使用Scala的错误处理类型(Option、Try 和 Either)”。
有关 allCatch 的更多信息,请参阅 scala.util.control.Exception 的Scaladoc页面( https://oreil.ly/6pUah )。
4.16 在try/catch/finally块中使用变量之前先声明该变量
问题
你想在try块中使用一个对象,并且需要在finally代码中访问它,例如当你需要调用某个对象的close方法时。
解决方案
通常,在try/catch块之前将你的字段声明为Option,然后在try子句中将变量绑定到一个Some。如下所示,其中sourceOption字段声明在try/catch块之前,并在try子句中进行赋值:
这是一个预先写好的例子 —— 16.1小节“读取文本文件”,展示了很多更好的读取文件的方法 —— 但它确实展示了这种方式。首先,在try块之前定义一个var字段作为Option:
然后,在try子句中,将一个Some值赋值给该变量:
当你有一个资源要关闭时,使用像这样的技术(尽管 16.1 节“读取文本文件”也展示了一种更好的关闭资源的方法)。请注意,如果在此代码中抛出异常,则finally里面的sourceOption将为None值。如果没有抛出异常,将执行match表达式的Some分支。
讨论
本小节的一个关键点是了解声明未初始化的Option字段的语法:
也可以使用第二种形式,但首选第一种形式:
不要使用null值
当我第一次开始使用Scala时,我认为编写此代码的唯一方法是使用 null 值。以下代码展示了我在检查电子邮件帐户的应用程序中使用的方法。此代码中的store和inbox字段被声明为具有Store和Folder类型的 null 字段(来自javax.mail包):
但是,在Scala工作中,甚至让你有机会忘记空值的存在,因此不推荐使用这种方法。
另见
有关 (a) 如何不使用 null 值以及 (b) 如何使用 Option、Try 和 Either 的更多详细信息,请参阅这些章节:
24.5小节,“消除代码中的null值”
24.6小节,“使用Scala的错误处理类型(Option、Try 和 Either)”
24.8小节,“使用高阶函数处理Option值”
每当你编写需要在启动时打开资源并在完成时关闭资源的代码时,使用 scala.util.Using 对象( https://oreil.ly/N47eZ )会很有帮助。参阅16.1小节“读取文本文件”,以了解如何使用这个对象以及更好的读取文本文件的方法。
此外,24.8小节“使用高阶函数处理Option值”,展示了除了使用match表达式之外的其他处理Option值的方法。
4.17 创建自己的控制结构
问题
你想定义自己的控制结构,简化代码或创建领域特定语言 (DSL)。
解决方案
得益于多参数列表、传名参数、扩展方法、高阶函数等功能,你可以自定义创建一个像控制结构一样的有效代码。
例如,假设 Scala 没有自己的内置 while 循环,而你想创建自己的自定义whileTrue循环,可以像这样使用它:
要创建此whileTrue控制结构,请定义一个名为whileTrue的方法,该方法采用两个参数列表。第一个参数处理判断条件——在该示例中,i < 5——第二个参数是用户想要运行的代码块,即大括号之间的代码。将这两个参数定义为传名参数。因为whileTrue只用于副作用,比如更新可变变量或打印到控制台,所以声明它返回Unit。方法大致的签名如下所示:
实现方法主体的一种方法是编写递归算法。此代码展示了一个完整的解决方案:
在这段代码中,对testCondition求值,如果条件为真,则执行codeBlock,然后递归调用whileTrue。它一直在调用自己,直到测试条件返回false。
要测试此代码,首先将其导入:
然后运行前面显示的whileTrue循环,你会看到它的工作原理是符合预期的。
讨论
Scala语言的创建者有意识地决定不在Scala中实现某些关键字,而是通过Scala库实现功能。例如,Scala没有内置的break和continue关键字。相反,它通过一个库来实现它们,正如我在我的博客文章 “Scala:如何在 for 和 while 循环中使用 break 和 continue”( https://oreil.ly/KOAHI )中所描述的那样。
如解决方案中所示,创建自己的控制结构的能力来自以下功能:
多个参数列表让你可以像我对whileTrue 所做的那样:创建一个参数组用于测试条件,第二组用于代码块。
传名参数还可以让你做我对whileTrue 所做的事情:接受的参数在方法内部访问它们之前是不会对其进行评估的。
类似地,中缀表示法、高阶函数、扩展方法和流式接口等其他特性允许你创建其他自定义控制结构和 DSL。
传名参数
传名参数是whileTrue控制结构的重要组成部分。在Scala中,重要的是要知道使用 => 定义方法参数的语法:
你正在创建所谓的传名调用或传名参数。仅当在你的方法内部访问时才评估计算传名参数,因此,正如我所写的博客文章“如何在Scala中使用传名参数”( https://oreil.ly/fuWGM )和“Scala和传名调用参数”( https://oreil.ly/shdre ),这些参数的更准确的名称是“访问时求值”。那是因为这正是它们的工作方式:只有在方法内部访问它们时才会评估计算它们。正如我在第二篇博文中所指出的,Rob Norris进行了比较,即传名参数就像接收一个def方法。
另一个例子
在whileTrue示例中,我使用递归调用来保持循环运行,但对于更简单的控制结构,你不需要递归。例如,假设你想要一个接受两个测试条件的控制结构,如果两个测试条件都为真,就运行提供的代码块。使用该控制结构的表达式如下所示:
在这种情况下,将doubleIf定义为采用三个参数列表的方法,每个参数都是一个传名参数:
因为doubleIf只需要执行一次测试,不需要无限循环,因此不需要在其方法体中进行递归调用。它只是检查两个测试条件,如果它们都为true,则执行codeBlock。
另见
David Pollak (Apress) 所著的 Beginning Scala 一书中展示了我最喜欢使用这种技术的一个方法。虽然它已被scala.util.Using废弃对象,我在这篇博文“The using Control Structure in Beginning Scala“( https://oreil.ly/fiLHH )中描述了该技术的工作原理。
Scala Breaks 类用于在for循环中实现break和continue功能,我写了一篇关于它的文章:“Scala: How to Use break and continue in for and while Loops”( https://oreil.ly/KOAHI )。Breaks类源代码相当简单,并提供了另一个如何实现控制结构的示例。你可以在其Scaladoc页面( https://oreil.ly/xI78S )上以找到其源代码。
最后更新于
这有帮助吗?