2. 字符串

作为程序员,我们经常和字符串打交道,如名字,地址,电话号码等等。Scala提供的字符串操作非常棒,因为除了拥有Java字符串的全部功能以外,还有一些新的扩展。本章将会在一些小节中介绍其字符串格式化和正则表达式相关的功能,而其他的小节则会展示Scala字符串一些独有的功能。

Scala与Java语法一个最大的区别是字符串的声明方式。所有的Scala变量都是以 valvar 的形式声明的,因此一个字符串变量通常是这样创建的:

    val s = "Hello, world"

这个表达式与下面的Java代码等价:

    final String s = "Hello, world"

在Scala中,一般来说总是将变量声明为 val ,除非有充分的理由使用 var。 (纯函数式编程要求则更严格,禁止使用 var。)

也可以 显示 声明成 字符串 类型:

    val s: String = "Hello, world" // 不要这么做
           ------

这么做只会让代码变得冗长,所以并不推荐这么做。因为Scala的 类型推断 非常强大,第一个例子中使用的隐式语法已经是首选方式了。在实际情况下,我其实只有在调用一个方法并且不确定其返回类型时,才会在创建变量时明确声明其类型,如:

    val s: String = someObject.someMethod(42)

Scala 字符串特性

赋予Scala字符串力量(超级力量!)的特性有:

  • 能使用==来比较字符串的相等

  • 多行字符串

  • 字符串插值,可以写出类似 println(s"Name: $name") 的代码

  • 数十种额外的函数方法可以将字符串当作字符序列来处理

本章中的小节展示了所有这些功能。

字符串是字符序列

上面提到的一个要点是,Scala的字符串可以被当作字符序列来看待,也就是当作 Seq[Char] 。因此在以下例子中的字符串:

    val s = "Big Belly Burger"

下面是一些“序列”常用的方法,可以在字符串上进行调用:

    s.count(_ == 'B') // 3
    s.dropRight(3) // "Big Belly Bur"
    s.dropWhile(_ != ' ') // " Belly Burger"
    s.filter(_ != ' ') // "BigBellyBurger"
    s.sortWith(_ < _) // " BBBeeggillrruy"
    s.take(3) // "Big"
    s.takeRight(3) // "ger"
    s.takeWhile(_ != 'r') // "Big Belly Bu"

这些方法都是 Seq 的标准方法,将在第11章中深入介绍。

链式方法调用

Seq 上所有的方法都是“函数式”的,这意味着它们不会改变现有的序列,而是在调用时返回一个新的序列,除了 foreach 它返回 Unit 。由于这种函数式特点,可以在字符串上使用链式调用:

    scala> "scala".drop(2).take(2).capitalize

    res0: String = Al

如果之前没有见过这种写法,这里简单介绍一下这个例子的工作原理:drop 是集合的一个方法,它从集合的开头丢弃指定数量的元素,保留剩余的元素。当这里调用 drop(2) 时,它从字符串( scala )中丢掉前两个字符( sc ),并返回剩余的元素:

    scala> "scala".drop(2)

    res0: String = ala

然后, take(2) 方法 保留 它所给的字符串“ala”中的前2个字符,并丢弃其他字符:

    scala> "scala".drop(2).take(2)

    res1: String = al

最后,调用 capitalize 得到最终结果:

    scala> "scala".drop(2).take(2).capitalize

    res2: String = Al

如果不熟悉这样的链式调用,它也被称为流式编程风格。参阅8.8小节“对流式编程风格的支持”以了解更多的信息。在函数式编程中这种代码非常常见,每个函数都是纯函数并返回一个值。这种风格同样在RxJava和RxScala等Rx技术中很流行,同时也被大量用于Spark中。

这些方法从哪来?

熟悉Java的人都知道Java的 字符串 类并没有 capitalize 方法,所以Scala上有这个方法可能会让人惊讶。Scala字符串在Java字符串的基础上扩充了几十个额外的方法,而这些方法可以通过Eclipse或者IntelliJ IDEA等IDE的“代码辅助”功能看到。

而当通过这种方式看到这些可用的方法并且得知Scala没有字符串类时,会不会觉得很神奇。如果没有字符串类,字符串是如何拥有这些方法的?

其工作原理是Scala通过隐式转换和扩展方法“继承”了Java的字符串类,而完成向其添加方法的功能。隐式转换 是Scala 2中向封闭类添加方法的方式,而 扩展方法(extension methods) 则是Scala3中使用的方式。关于如何创建扩展方法的细节,参阅8.9小节,“用扩展方法向封闭类添加新方法”。

虽然这可能会随着时间的推移而改变,但在Scala3.0中,Scala 字符串 许多额外方法是定义在 StringOps 类中,定义在 StringOps 类中的方法会随着 scala.Predef 自动导入到代码中,在 Scala 2.13的 Predef 对象中(它也被Scala3所使用)可以找到这个隐式转换以及相关文档:

    /** The `String` type in Scala has all the methods of the underlying 
     * `java.lang.String`, of which it is just an alias ... In addition, 
     * extension methods in scala.collection.StringOps 
     * are added implicitly through the conversion augmentString.
     */ 
    @inline implicit def augmentString(x: String): StringOps = new StringOps(x)

augmentString 将一个 字符串 转换成一个 StringOps 。这样做会将 StringOps 中的方法添加到所有的Scala 字符串 实例中。这其中包括像 droptakefilter 这些能把字符串当作字符序列处理的方法。

阅读Predef源代码 -- TODO 松鼠图

我强烈建议Scala的初学者看一下Scala 2.13的 scala.Predef( https://www.scala-lang.org/api/current/scala/Predef$.html ) 的源码,可以在前面的Scaladoc页面上找到源代码链接,它提供了许多和Scala编程特性相关的例子。可以看到它是如何引入其他像 StringOpsWrappedString 这样的类型的。

2.1 字符串相等

问题

你想知道如何比较两个字符串是否相等,即它们所包含的字符序列是否相同。

解决方案

在Scala中可以通过 == 运算符来比较两个 字符串 。对于下面这些字符串:

    val s1 = "Hello"
    val s2 = "Hello"
    val s3 = "H" + "ello"

可以像下面这样来测试它们是否相等:

    s1 == s2 //true
    s2 == s3 //true

使用==方法的好处在于即使一个字符串是null,在测试的过程中也不会抛出 NullPointerException

    val s4: String = null // String = null
    s3 == s4 // false
    s4 == s3 // false

如果想不区分大小写的比较两个字符串,一种方法是将两个字符串都转成大写或者小写然后通过==进行比较:

    val s1 = "Hello" // Hello
    val s2 = "hello" // hello
    s1.toUpperCase == s2.toUpperCase // true

同样也可以使用Java 字符串 提供的 equalsIgnoreCase 方法:

    val a = "Kimberly"
    val b = "kimberly"
    a.equalsIgnoreCase(b) // true

注意,虽然对 null 字符串进行相等测试不会抛异常,但对null 字符串调用方法则会抛出 NullPointerException

    val s1: String = null 
    val s2: String = null

    scala> s1.toUpperCase == s2.toUpperCase 
    java.lang.NullPointerException // more output here ...

讨论

在Scala中使用==来判断两个对象是否相等,这和Java使用 equals 来判断两个对象相等是不一样的。

==方法是定义在所有引用类型的基类 AnyRef 上的,它会先检查是否是 null 值,然后对第一个对象调用 equals 来测试这两个对象是否相等。因此,当比较字符串相等时不需要检查 null


最好不要使用Null

在日常使用Scala中,从来不 需要使用 null 值。本节中的讨论旨在帮助理解当使用Java库或者其他库在遇到 null 值时 == 是如何工作的。

如果想像Java那样的语言使用 null 值,建议使用 Option 来代替。我发现,如果假设Scala没有 null 关键字,对写代码甚至很有帮助。参阅 24.6节“使用 Scala的错误处理类型(Option, Try 和 Either)”来获取更多的例子与信息。

在Scala3中甚至可以改变类型系统,将那些继承自 AnyRef 的类型如 String、List、Option 等等设置成 不可空。使用实验性的编译器选项 -Yexplicit-nulls 通过改变Scala的类型继承关系使得下面这段代码无法通过编译:

    val s: String = null 
    // won’t compile with '-Yexplicit-nulls'

参阅Scala显示使用null( https://docs.scala-lang.org/scala3/reference/other-new-features/explicit-nulls.html )获取更多信息。


另见

更多关于 ==equals 方法定义的信息见小节5.9 “定义相等方法(对象相等)”

2.2 创建多行字符串

问题

你想在Scala中创建多行字符串,就像用其他语言的 heredoc 语法一样。

解决方案

在Scala中,可以通过使用三个引号将想创建成多行字符串的文本引起来即可:

    val foo = """This is 
        a multiline 
        String"""

虽然这样能运行,但是这个例子中,第二行和第三行会在行首出现空白。打印这个字符串会看起来像这样:

    This is
        a multiline 
        String

有好几种方法可以解决这个问题,最好的解决方式是在多行字符串的末尾加上 stripMargin 方法,并使用管道符号(|)作为第二行以及之后所有行的开头:

    val speech = """Four score and 
                   |seven years ago""".stripMargin

如果不喜欢使用|符号,则只需要在调用 stripMargin 指定想要的字符即可:

    val speech = """Four score and 
                   #seven years ago""".stripMargin('#')

当然也可以对第二行开始所有的行进行左对齐:

    val foo = """Four score and 
    seven years ago"""

所有以上方法都会产生一个同样的结果,即一个多行字符串,其中每一行都是左对齐的:

    Four score and 
    seven years ago

这些方法生成的结果是一个真正的多行字符串,每一行的末尾都有一个隐藏的 字符。如果想把这个多行字符串转换成一个连续的行,可以在 stripMargin 后调用 replaceAll 方法,将所有的换行符替换成空格:

    val speech = """Four score and
                   |seven years ago 
                   |our fathers...""".stripMargin.replaceAll("\n", " ")

这会生成:

    Four score and seven years ago our fathers...

讨论

Scala多行字符串的另一大特性是,可以在字符串中直接使用单引号和双引号而不用转义:

    val s = """This is known as a
              |"multiline" string 
              |or 'heredoc' syntax.""". stripMargin.replaceAll("\n", " ")

结果为:

    This is known as a "multiline" string or 'heredoc' syntax.

2.3 分隔字符串

问题

你想根据一个字段分隔符将一个字符串分成几个部分,比如从逗号分隔符(CSV)或者管道分隔文件中得到特定的字符串。

解决方案

使用 字符串 对象上一个可用的 split 方法:

    scala> "hello world".split(" ")
    res0: Array[String] = Array(hello, world)

split 方法返回一个字符串数组。可以将其当作一个普通的数组:

    scala> "hello world".split(" ").foreach(println)
    hello
    world

讨论

可以使用简单字符作为分隔符来分隔字符串,就像用逗号作为CSV文件分隔符一样:

    scala> val s = "eggs, milk, butter, Cocoa Puffs"
    s: java.lang.String = eggs, milk, butter, Cocoa Puffs

    // 1st attempt
    scala> **s.split(",")**
    res0: Array[String] = Array("eggs", " milk", " butter", " Cocoa Puffs")

使用这种方法,最好对每个字符串进行trim。在返回数组之前。使用 map 方法对每个字符串调用 trim

    // 2nd attempt, cleaned up
    scala> s.split(",").map(_.trim)
    res1: Array[String] = Array(eggs, milk, butter, Cocoa Puffs)

也可以使用正则表达式来分隔一个字符串,下面这个例子告诉我们如何根据空白字符来分隔字符串:

    scala> "Relax, nothing is under control".split("\\s+")
    res0: Array[String] = Array(Relax,, nothing, is, under, control)

不是所有CSV文件都一样 -- TODO 小鸟图

注意,一些声称自己是CSV文件的文件实际上可能在其字段中包含逗号,他们通常会使用单引号或者双引号引起来,而其他文件可能在其字段中包含换行符。处理这类文件的算法将比所示方法更复杂。更多信息见维基百科关于CSV( https://en.wikipedia.org/wiki/Comma-separated_values )文件的条目。

关于那个 split 方法

split 方法有多个重载版本,有些来自于Java的 String 类,而有的版本来自于 Scala 的 StringOps ,例如,如果用 Char 作为参数而不是 String 作为参数来调用 split,就使用的是 StringOpssplit

    // split with a String argument (from Java) 
    "hello world".split(" ") //Array(hello, world)

    // split with a Char argument (from Scala) 
    "hello world".split(' ') //Array(hello, world)

2.4 字符串中的变量替换

问题

你想在一个字符串中使用变量替换,就像其他语言如Perl、PHP和Ruby中一样。

解决方案

要在Scala中使用基本的字符串插值,需要在字符串前加上字母s,并在字符串中包含需要替换的变量,变量名称前面需要加上 $ 字符,参照下面 println 中的语句:

    val name = "Fred"
    val age = 33
    val weight = 200.00

    scala> println(s"$name is $age years old and weighs $weight pounds.")
    Fred is 33 years old and weighs 200.0 pounds.

根据Scala官方的字符串插值文档,当在字符串前面加上字母 s 时,就是在创建一个经过 处理 的字符串字面量。这个例子使用了“s 字符串插值器”,它可以让你在字符串中嵌入变量,变量被替换成它们的值。

在字符串字面量中使用表达式

除了把简单的变量放在字符串中,还可以将 表达式 放在字符串的大括号里,在下面的例子中,会把 age 加一后的结果放到字符串中:

    scala> println(s"Age next year: ${age + 1}")
    Age next year: 34

下面这个例子展示了在大括号中使用相等判断:

    scala> println(s"You are 33 years old: ${age == 33}")
    You are 33 years old: true

在打印对象字段时也需要使用大括号:

    case class Student(name: String, score: Int) 
    val hannah = Student("Hannah", 95)

    scala> println(s"${hannah.name} has a score of ${hannah.score}") 
    Hannah has a score of 95

注意,如果不使用大括号去打印对象的字段,打印出的信息会与所预期不相符:

    // error: this is intentionally wrong 
    scala> println(s"$hannah.name has a score of $hannah.score") 
    Student(Hannah,95).name has a score of Student(Hannah,95).score

讨论

放在字符串前面的 s 实际上是一个方法。虽然这似乎比直接把变量放在字符串里要显得不是那么方便,但这么做至少有两个好处:

  • Scala提供了其他插值函数,这让你有更多的掌控力

  • 任何人都可以定义自己的字符串插值函数。如,Scala的SQL库就利用了这种能力,可以写出像 sql "SELECT * FROM USERS " 的查询。

我们来看看另外两个Scala内置的插值函数。

字符串插值f(printf 格式化)

在解决方案的例子中, weight 打印为 200.0 。 这完全正确,但如果想在 weight上多打印几位小数,或者完全删除它们,该怎么做呢?

这个简单的需求产生了“f字符串插值”,一个可以通过 printf 格式化内部字符串的方法。接下来的例子会说明如何打印保留两位小数的 weight

    scala> println(f"$name is $age years old and weighs $weight%.2f pounds.")
    Fred is 33 years old and weighs 200.00 pounds.

不保留小数:

    scala> println(f"$name is $age years old and weighs $weight%.0f pounds.")
    Fred is 33 years old and weighs 200 pounds.

如上所述,用这种方法只需要做到如下两步:

  1. 在字符串前加 f

  2. 在变量后使用printf风格的格式化指定符

printf 格式化指定符 -- TODO 小鸟图

最常用的 printf 格式化指定符会在2.5节中被列举。

虽然这些例子使用了println方法,但需要注意的是,完全可以将变量替换的结果赋给一个新的变量,类似于在其他语言中使用sprintf

    scala> val s = f"$name, you weigh $weight%.0f pounds."
    s: String = Fred, you weigh 200 pounds.

现在 s 只是一个普通的字符串,可以用在任何需要字符串的地方。

raw插入符

除了 sf 字符串插入符之外,Scala还包含另一个叫做 raw 的插入符。使用 raw 插入符不会对字符串里的任何转义字符进行转义。下面这个例子是对 raws 插入符进行比较:

    scala> s"foo\nbar" 
    val res0: String = foo 
    bar

    scala> raw"foo\nbar" 
    res1: String = foo\nbar

如上所示, s 会对 转义成换行符而 raw 不对其做任何转义,只是将其当作普通字符处理。

创建自定义插值器 -- TODO 松鼠图

除了 sfraw 插值器,还可以定义自己的插值器。2.11 小节给出了如何创建自己的插值器的例子。

另见

  • 2.5小节列举了许多常用的字符串格式化的字符。

  • Oracle Formatter class documentation( https://oreil.ly/gEAsi )有完整的字符串格式化字符的列表。

  • The official Scala string interpolation page( https://oreil.ly/A3hqn )有更多关于插值器的细节

  • 2.11 小节给出了如何创建自己的插值器的例子。

2.5 格式化字符串输出

问题

你想格式化字符串的输出,包括整数、浮点数、双精度浮点数以及字符的字符串。

解决方案

使用 f 插值器的 print 风格字符串格式化。许多配置选项在下面的例子中有展示。

日期/时间格式化 -- TODO 小鸟图

日期和时间的格式化这些主题将在3.11小节“格式化日期”中讨论。

格式化字符串

字符串可以用%s格式符进行格式化。这些例子展示了如何对字符串进行格式化,包括如何在一定的空间内对其进行左对齐和右对齐:

    val h = "Hello"

    f"'$h%s'" // 'Hello'
    f"'$h%10s'"   // '     Hello'
    f"'$h%-10s'"  // 'Hello     '

我发现当把变量名放在大括号里时,可以让格式化字符串可读性更高,所以在本书剩下部分将会使用这种风格:

    f"'${h}%s'" // 'Hello'
    f"'${h}%10s'" // '     Hello'
    f"'${h}%-10s'"// 'Hello     '

浮点数格式化

浮点数是用 %f 格式符进行打印的。下面的例子包括了 DoubleFloat 的值,展示了浮点数格式化的效果:

    val a = 10.3456        // a: Double = 10.3456
    val b = 101234567.3456 // b: Double = 1.012345673456E8

    f"'${a}%.1f'"    // '10.3'
    f"'${a}%.2f'"    // '10.35'
    f"'${a}%8.2f'"   // '   10.35'
    f"'${a}%8.4f'"   // ' 10.3456'
    f"'${a}%08.2f'"  // '00010.35'
    f"'${a}%-8.2f'"  // '10.35   '

    f"'${b}%-2.2f'"  // '101234567.35'
    f"'${b}%-8.2f'"  // '101234567.35'
    f"'${b}%-14.2f'" // '101234567.35  '

这些例子展示了 Double 值的格式化方法,同样的语法对 Float 也适用:

    val c = 10.5f   // c: Float = 10.5
    f"'${c}%.1f'"   // '10.5'
    f"'${c}%.2f'"   // '10.50'

整数格式化

整数是用 %d 格式符进行打印的。下面的例子展示了整数填充和调整的效果:

    val ten = 10
    f"'${ten}%d'"   // '10'
    f"'${ten}%5d'"  // '   10'
    f"'${ten}%-5d'" // '10   '

    val maxInt = Int.MaxValue 
    f"'${maxInt}%5d'" // '2147483647'

    val maxLong = Long.MaxValue 
    f"'${maxLong}%5d'" // '9223372036854775807' 
    f"'${maxLong}%22d'"// '   9223372036854775807'

补0的方法

下面的例子展示了给整数补0的效果:

    val zero = 0 
    val one = 1 
    val negTen = -10 
    val bigPos = 12345 
    val bigNeg = -12345

    val maxInt = Int.MaxValue

    // non-negative integers
    f"${zero}%03d" // 000 
    f"${one}%03d" // 001 
    f"${bigPos}%03d" // 12345 
    f"${bigPos}%08d" // 00012345 
    f"${maxInt}%08d" // 2147483647 
    f"${maxInt}%012d" // 002147483647

    // negative integers
    f"${negTen}%03d" // -10
    f"${negTen}%05d" // -0010
    f"${bigNeg}%03d" // -12345
    f"${bigNeg}%08d" // -0012345

字符格式化

字符是用 %c 格式符进行打印的。下面的例子展示了格式化字符填充和调整的效果:

    val s = 's'

    f"|${s}%c|"   // |s|
    f"|${s}%5c|"  // |    s|
    f"|${s}%-5c|" // |s    |

f用于多行字符串

需要注意,f插值器同样对多行字符串起效,如下所示:

    val n = "Al"
    val w = 200.0
    val s = f"""Hi, my name is ${n}
      |and I weigh ${w}%.1f pounds.
      |""".stripMargin.replaceAll("\n", " ") 
    println(s)

这段代码的输出结果如下:

    Hi, my name is Al and I weigh 200.0 pounds.

如2.2小节提到的,当使用多行字符串时,也不需要转义单引号和双引号。

讨论

作为参考表2-1展示了常见的printf样式的格式符。

表2-1 常用printf样式格式符

格式符
描述

%c

字符

%d

十进制数字(整数,十进制)

%e

指数型浮点数

%f

浮点数

%i

整数(十进制)

%o

八进制数(八进制)

%s

字符串

%u

无符号十进制(整)数

%x

十六进制数(16进制)

%%

打印%字符

$$

打印$字符

为了更好的理解这些格式符的工作原理,下面这些例子展示了如何使用 %%$$

    println(f"%%")  // prints %
    println(f"$$")  // prints $

表2-2展示了在格式化字符串时可以用的特殊字符。

表2-2 能在格式化字符串时使用的字符序列

字符序列
描述

\b

退格

\f

分页

换行

回车

制表符

\\

\

\"

双引号

\'

单引号

\u

Unicode字符的起始

另见

  • java.util.Formatter 类文档( https://oreil.ly/PWCim )展示所有可用的格式化字符。

2.6 一次处理字符串中一个字符

问题

你想知道如何遍历一个字符串中的每个字符,并在遍历字符串时对每个字符执行操作。

解决方案

如果需要转换字符串中的字符以得到一个新的结果(而不是副作用),应该使用 for 表达式,或高阶函数(HOF)如mapfilter 。如果想做一些有副作用的的操作,如打印输出,那么应该使用简单的for 循环或者使用像 foreach 这样的方法。如果需要把字符串当作一个字节序列,使用 getBytes 方法。

转换

以下是一个 for 表达式的例子,一个带有 yieldfor 循环,它对字符串中每一个字符进行转换:

    scala> val upper = for c <- "yo, adrian" yield c.toUpper 
    upper: String = YO, ADRIAN

下面是与之等价的使用 map 方法:

    scala> val upper = "yo, adrian".map(c => c.toUpper) 
    upper: String = YO, ADRIAN

这段代码还可以使用Scala神奇的下划线字符来变得更短:

    scala> val upper = "yo, adrian".map(_.toUpper)
    upper: String = YO, ADRIAN

使用HOF和纯转换函数的好处是,可以将其串联起来,得到想要的结果。下面是一个先调用 filter 然后调用 map 的例子:

    "yo, adrian".filter(_ != 'a').map(_.toUpper) // String = YO, DRIN

副作用

当需要执行一个副作用时,如将一个字符串中的每个字符打印到STDOUT,就可以使用一个简单for循环:

    scala> for c <- "hello" do println(c)
    h
    e
    l
    l
    o

也可以使用 foreach 方法:

    scala> "hello".foreach(println)
    h
    e
    l
    l
    o

处理字符串字节

如果需要将一个字符串作为一个字节序列来处理,也可以使用getBytes方法。 getBytes返回一个字符串的字节序列集合:

    scala> "hello".getBytes
    res0: Array[Byte] = Array(104, 101, 108, 108, 111)

getBytes之后添加foreach展示了对每个字节进行操作的一种方式:

    scala> "hello".getBytes.foreach(println)
    104 
    101 
    108 
    108 
    111

写一个能传入map的方法 -- TODO 松鼠图

要写一个可以传入map的方法来对字符串中的字符进行操作,需要定义一个Char作为输入,然后在方法中对该Char进行逻辑运算。当运算完成后,返回你的算法所需的数据类型。虽然下面的算法很短,但它演示了如何创建一个自定义方法并将该方法传入map

    // write your own method that operates on a character 
    def toLower(c: Char): Char = (c.toByte+32).toChar 
    // use that method with map 
    "HELLO".map(toLower) 
        // String = hello

参阅10.2小节“将函数作为变量传递”中对Eta Expansion的讨论,以了解更多关于如何将一个方法传递给另一个以函数作为参数的方法的细节。

讨论

因为Scala将 字符串 视为一个字符序列 Seq[Char] 所有下面的例子都能正常地工作:

for+yield

如果你是从命令式语言(Java、C、C#等)转到Scala,一开始使用 map 方法可能会不太适应。在这种情况下,你可能更愿意写一个类似于这样的 for 表达式:

    val upper = for c <- "hello, world" yield c.toUpper

在for循环中加入yield,本质上是将每次循环迭代的结果放入一个临时的保留区域。当循环完成时,保留区中的所有元素将作为一个单一的集合返回;也可以说它们是由for循环产生的。

虽然我(强烈)建议去熟悉map方法的工作原理,但如果想在刚开始的时候使用 for 表达式,了解for表达式实际上对使用filtermap方法是很有帮助的:

    val result = "hello, world"
                    .filter(_ != 'l') 
                    .map(_.toUpper)

与下面的for 表达式等价:

    val result = for 
        c <- "hello, world"
        if c != 'l'
    yield 
        c.toUpper

几乎不用写自定义的for循环 -- TODO 松鼠图

正如我在Scala官方网站上的第一版Scala Book中写到的,Scala集合类的一大优势是它们带有几十个预置的方法。这样做的一大好处是,每次需要处理一个集合时,你不再需要编写自定义的for loops。如果这听起来还不够吸引人的话,这也意味着你不再需要阅读其他开发者编写的自定义的for循环。;)

更重要的是,研究表明,开发人员花在阅读代码上的时间远远多于编写代码的时间,阅读/编写的比例估计最多20:1,最少也有10:1,因为我们花了这么多时间阅读代码,所以代码既要简洁又要可读是很重要的,也就是Scala开发者所说的表现力

转换方法

一旦适应了 "Scala方式"即利用Scala内置的转换函数,这样就不会写自定义的for循环了,而是去使用map方法。这两个map表达式产生的结果都与for表达式相同:

    val upper = "hello, world".map(c => c.toUpper) 
    val upper = "hello, world".map(_.toUpper)

map这样的转换方法既可以接受一个如上所示的单行匿名函数,也可以接受一个更大代码块算法。下面是一个使用多行代码块的map的例子:

    val x = "HELLO".map { c =>
        // 'c' represents each character from "HELLO" ('H', 'E', etc.) 
        // that’s passed one at a time into this algorithm 
        val i: Int = c.toByte + 32 
        i.toChar
    }

    // x: String = "hello"

注意,这个算法是用大括号括起来的,当创建一个像这样的多行代码块时,都需要使用大括号。

你可能从这些例子中推测出map 有一个内置的循环,在这个循环中,它一次将一个Char传递给它的参数函数。

在继续之前,这里还有几个字符串转换方法的例子:

    val f = "foo bar baz" 
    f.dropWhile(_ != ' ') // " bar baz"
    f.filter(_ != 'a')    // foo br bz
    f.takeWhile(_ != 'r') // foo ba

副作用方法

map或者for/yield 方法是用来将一个集合转化为另一个集合的,而foreach方法是用来对每个元素进行操作而不返回结果的,这完全可以从它的方法签名的返回值是Unit 推断出来:

    def foreach[U](f: (A) => U): Unit
                                 ----

所以说,foreach 能很好的处理副作用,比如打印:

    scala> "hello".foreach(println) 
    h 
    e 
    l 
    l 
    o

一个完整的例子

下面的例子演示了如何在一个字符串上调用 getBytes,然后将一个代码块传入foreach,用来计算一个字符串的Adler-32( https://en.wikipedia.org/wiki/Adler-32 )校验值:

    /** 
     * Calculate the Adler-32 checksum using Scala. 
     * @see https://en.wikipedia.org/wiki/Adler-32 
     */
    def adler32sum(s: String): Int = 
        val MOD_ADLER = 65521
        var a = 1
        var b = 0

        // loop through each byte, updating `a` and `b` 
        s.getBytes.foreach{ byte =>
            a = (byte + a) % MOD_ADLER
            b = (b + a) % MOD_ADLER 
        }
        // this is the return value.
        // note that Int is 32 bits, which this requires. 
        b * 65536 + a // or (b << 16) + a

    @main def adler32Checksum =
        val sum = adler32sum("Wikipedia") 
        println(f"checksum (int) = ${sum}%d") 
        println(f"checksum (hex) = ${sum.toHexString}%s")

@main 方法中的第二个 println 语句打印出十六进制值 11e60398,这与Adler-32算法页面上的 0x11E60398 相匹配。

请注意,我在这个例子中使用foreach而不是map,目的是在字符串的每个字节上循环,然后对每个字节做一些操作,而不从循环中返回任何东西。与map 不同的是这个算法会更新可变变量ab

另见

  • Scala编译器会将for循环翻译成foreach方法调用。如果循环中有一个或多个if语句(守卫)或yield表达式,情形则会变得复杂的多。这在我的 Functional Programming, Simplifiedhttps://alvinalexander.com/scala/functional-programming-simplified-book/ )(CreateSpace出版社)一书中有详细讨论。

  • Adler是基于维基百科对Adler-32 checksum algorithm( https://en.wikipedia.org/wiki/Adler-32 )的讨论

2.7 字符串中的查找模式

问题

你想要在 String 中搜索,看其是否包含一个正则表达式。

解决方案

在一个 字符串 上调用 .r 方法来创建一个 Regex 对象,如果想在一个字符串中查找一个 Regex 匹配的模式则使用其 findFirstIn 方法,如果想查找所有匹配的模式则使用其 findAllIn 方法。

为了验证上面的说法,首先需要为想要搜索的模式创建一个 Regex ,下面这个例子是一个查找一个或多个数字字符的序列:

    val numPattern = "[0-9]+".r  // scala.util.matching.Regex = [0-9]+

下一步,创建一个用来搜索的字符串样例:

    val address = "123 Main Street Suite 101"

findFirstIn 方法将找到第一个匹配结果:

    scala> val match1 = numPattern.findFirstIn(address) 
    match1: Option[String] = Some(123)

注意,这个方法的返回值是 Option[String]

接下来看看寻找多个匹配时使用的 findAllIn 方法:

    scala> val matches = numPattern.findAllIn(address) 
    val matches: scala.util.matching.Regex.MatchIterator = <iterator>

如上所示,findAllIn 返回一个迭代器,可以在这个迭代器上循环的访问每一个匹配的结果:

    scala> matches.foreach(println) 
    123 
    101

如果findAllIn没有找到任何结果,会返回一个空的迭代器,所以仍然可以调用其方法而不会抛出空指针异常。如果想将结果转换成一个 Vector 则只要在其后面继续调用 toVector 即可:

    scala> val matches = numPattern.findAllIn(address).toVector 
    val matches: Vector[String] = Vector(123, 101)

如果没有任何匹配,上面这种方式将产生一个空的 Vector 。其他方法如 toListtoSeqtoArray 在没有任何匹配时也是可用的。

讨论

在一个字符串上使用 .r 方法是创建一个 Regex 对象的最简单方法。另一种方法是导入 Regex 类,创建一个Regex 实例,然后以同样的方式使用该实例:

    import scala.util.matching.Regex 
    val numPattern = Regex("[0-9]+")
    val address = "123 Main Street Suite 101" 
    val match1 = numPattern.findFirstIn(address)

虽然这么做会需要更多的代码,但是也更明显,因为我发现自己很容易忽视掉字符串末尾的 .r (然后会花几分钟来搞清楚自己代码竟然能正常工作的原因)。

关于findFirstIn的返回类型是Option的一些简要讨论

正如前面解决方案里提到的, findFirstIn 方法会找到例子中第一个匹配项然后返回 Option[String]

    scala> val match1 = numPattern.findFirstIn(address) 
    match1: Option[String] = Some(123)

因为 Option/Some/None 将在24.6小节“使用Scala的错误处理类型(Option、Try以及Either)”介绍,所以不会在这里详细讨论他们,Option 简单来说是一个容纳0个或1个值的容器,在上面 findFirstIn 的例子里,如果它成功找到一个匹配,就会将用 Some 包起来的字符串“123”,也就是 Some("123") 。如果,在字符串中没有找到任何匹配项,就会返回一个 None

另见

  • 参阅Scala Regex 类文档( https://oreil.ly/rdiBW ),了解更多使用正则表达式的方法。

  • 参阅24.6小节“使用Scala的错误处理类型(Option、Try以及Either)”,以了解如何处理 Option 值的细节。

2.8 字符串中的替换模式

问题

你想在字符串中搜索某个正则表达式并将其替换。

解决方案

由于字符串是不可变的,所以不能直接对它进行查找并替换的操作,但仍然可以创建一个新的字符串,其中包含被替换的内容。有几种方法可以做到以上的操作。

可以调用字符串的 replaceAll 方法,并将结果赋给一个新的变量:

    scala> val address = "123 Main Street".replaceAll("[0-9]", "x")
    address: String = xxx Main Street

也可以创建一个正则表达式对象,并调用其 replaceAllIn ,与上面一样记得要把结果赋给一个新的变量:

    scala> val regex = "[0-9]".r
    regex: scala.util.matching.Regex = [0-9]

    scala> val newAddress = regex.replaceAllIn("123 Main Street", "x")
    newAddress: String = xxx Main Street

replaceFirst 可以只替换第一次出现的模式:

    scala> val result = "123".replaceFirst("[0-9]", "x")
    result: String = x23

同样,也可以使用 Regex 对象的 replaceFirstIn

    scala> val regex = "H".r
    regex: scala.util.matching.Regex = H

    scala> val result = regex.replaceFirstIn("Hello world", "J") 
    result: String = Jello world

另见

  • scala.util.matching.Regex documentation( https://oreil.ly/fZFEM )包含了更多关于创建和使用 Regex 的例子。

2.9 提取字符串中与模式相匹配的部分

问题

能修提取一个字符串中符合指定的正则表达式模式的一个或多个部分。

解决方案

将想提取的正则表达式(Regex)模式进行定义,并使用小括号括起来,这样就可以将其作为正则表达式组来提取。步骤如下,首先,定义所需的模式:

    val pattern = "([0-9]+) ([A-Za-z]+)".r

这将创建一个scala.util.matching.Regex类的实例。这个regex用文字表达则是:一个或多个数字紧跟一个空格再紧跟上一个或多个字母表字符。

接下来就是如何从目标字符串中提取这组regex:

    val pattern(count, fruit) = "100 Bananas" 
    // count: String = 100 
    // fruit: String = Bananas

如注释所示,这段代码从给定的字符串中提取了数字字段和字母数字字段,并将其作为两个独立的变量count和fruit。

讨论

这里展示的语法可能会让人感觉有点不寻常,因为这里好像把 pattern 定义为 val 字段了两次,但是在使用 匹配 表达式的真实的例子中,这种语法更加方便,可读性也更好。

试想一下,有一个像谷歌一样的搜索引擎,可以用各种短语来搜索电影。为了方便起见,可以让人们输入任何下面这些短语来获得科罗拉多州博尔德附近的电影列表:

    "movies near 80301" 
    "movies 80301" 
    "80301 movies" 
    "movie: 80301" 
    "movies: 80301" 
    "movies near boulder, co" 
    "movies near boulder, colorado"

可以允许使用所有这些短语的一种方法是定义一系列正则表达式模式来与它们匹配。只要定义好表达式,然后尝试将用户输入的任何内容与所有可能的表达式进行匹配即可。

作为一个例子,可以试想一下,只允许下面两种模式:

    // match "movies 80301" 
    val MoviesZipRE = "movies (\\d{5})".r

    // match "movies near boulder, co" 
    val MoviesNearCityStateRE = "movies near ([a-z]+), ([a-z]{2})".r

这些模式将与下面这些的字符串匹配:

    "movies 80301" 
    "movies 99676" 
    "movies near boulder, co" 
    "movies near talkeetna, ak"

一旦定义了允许匹配的 regex 模式,就可以用一个匹配表达式将它们与用户输入的任何文本进行匹配。在这个例子中,调用了一个名为 getSearchResults 的虚构的方法,该方法在发生匹配时返回一个 Option[List[String]]:

    val results = textUserTyped match
        case MoviesZipRE(zip) => getSearchResults(zip) 
        case MoviesNearCityStateRE(city, state) => getSearchResults(city, state) 
        case _ => None

如上所示,这种语法可以让匹配表达式可读性更好,对于要匹配的两种模式,都要调用 getSearchResults 方法的重载版本,在第一种情况下传递 zip 字段,在第二种情况下传递 citystate 字段。

值得注意的是,使用这种技术,正则表达式必须与整个用户输入相匹配。以下字符串使用所示的正则表达式模式将失败,因为它们在行尾有一个空白:

    "movies 80301 " 
    "movies near boulder, co "

可以通过修剪输入字符串来解决这个问题,或者也可以像实际应用中那样使用更复杂的正则表达式。

可以想象一下,你可以在许多不同的情况下使用这种模式匹配技术,包括匹配日期和时间格式、街道地址、人名以及许多其他情况。

另见

  • 参阅4.6小节,“像switch语句一样使用匹配表达式”,来了解更多匹配表达式的例子。

  • 在匹配表达式中,你可以看到 scala.util.matching.Regex 被用作一个提取器提取器将在7.8小节“使用unapply实现模式匹配”中讨论。

2.10 访问字符串中的字符

问题

你想要访问一个字符串中的特定位置的字符。

解决方案

使用Scala数组下标访问数组的方法,通过索引位置访问对应的字符,但请注意不要越界:

    "hello"(0)  // Char = h
    "hello"(1)  // Char = e
    "hello"(99) // throws java.lang.StringIndexOutOfBoundsException

讨论

这个小节存在的目的是因为在Java中大家使用charAt方法来达到这个目的。当然也可以在Scala中使用它,但这段代码会显得很啰嗦:

    "hello".charAt(0)   // Char = h
    "hello".charAt(99)  // throws java.lang.StringIndexOutOfBoundsException

在Scala中,首选方法是在解决方案中所示的使用数组下标访问的方法。

数组下标访问实际上是方法调用 -- TODO 鸽子图

Scala中使用数组下标访问既方便又好看,如果想知道Scala如何做到如此简单易懂的,可以看下面这个例子:

    "hello"(1) // 'e'

将会被Scala编译器翻译成:

    "hello".apply(1) // 'e'

在7.5小节“在对象中使用apply方法作为构造器”中有更多的细节来解释这个语法糖。

2.11 创建你自己的字符串插值器

问题

你想要创建自己的字符串插值器,就像Scala自带的 sfraw

解决方案

要创建自己的字符串插值器,你需要知道的是,当程序员写下 foo"a b c" 这样的代码时,这段代码被转化为 StringContext 类上的 foo 方法调用。具体来说,当写这段代码时:

    val a = "a"
    foo"a = $a"

会被翻译成:

    StringContext("a = ", "").foo(a)

因此,要创建一个自定义的字符串插值器,需要将 foo 创建为 StringContext 上的Scala 3扩展方法。此外还有一些额外的细节需要了解,我将在后面的一个例子中展示这些细节。

假设你想创建一个 caps 的字符串插值器,将字符串中的每个字都大写,像这样:

    caps"john c doe" // "John C Doe"

    val b = "b"
    caps"a $b c"     // "A B C"

要创建 caps ,需要将其定义为 StringContext 的一个扩展方法。因为要创建一个字符串插值器,所以该方法会返回一个字符串,所以解决方案应该是类似下面这样:

    extension(sc: StringContext) 
        def caps(?): String = ???

因为一个预插值字符串可以包含任何类型的多个表达式,所以需要定义 caps 接受一个 Any 类型的 varargs 参数,所以可以这样写:

    extension(sc: StringContext) 
        def caps(args: Any*): String = ???

要定义 caps 的函数体,接下来要知道的是,原始字符串以两个不同的变量形式出现:

  • scStringContext 的实例,通过迭代器提供其数据

  • args.iteratorIterator[Any] 的实例。

下面这段代码展示了使用上面所说的迭代器将 字符串 中每一个字符都变成大写的方法:

    extension(sc: StringContext)
        def caps(args: Any*): String = 
            // [1] create variables for the iterators. note that for an 
            // input string "a b c", `strings` will be "a b c" at this 
            // point.
            val strings: Iterator[String] = sc.parts.iterator 
            val expressions: Iterator[Any] = args.iterator

            // [2] populate a StringBuilder from the values in the iterators 
            val sb = StringBuilder(strings.next.trim) 
            while strings.hasNext do
                sb.append(expressions.next.toString) 
                sb.append(strings.next)

            // [3] convert the StringBuilder back to a String, 
            // then apply an algorithm to capitalize each word in 
            // the string 
            sb.toString 
              .split(" ") 
              .map(_.trim) 
              .map(_.capitalize) 
              .mkString(" ") 
        end caps 
    end extension

以下是对这段代码的简单描述:

  1. 首先,为两个迭代器创建变量。 strings 变量包含了输入字符串中的所有字符串字面量,而 expressions 则包含了代表输入字符串中所有表达式的值,比如 $a 变量。

  2. 接下来,我通过在 while 循环中对这两个迭代器进行迭代来填充一个 StringBuilder 。这就开始把字符串重新组合起来,包括所有的字符串字面量和表达式。

  3. 最后, StringBuilder 被转换回一个 字符串 ,然后调用一系列的转换函数,将字符串中的每个字符变成大写。

当然还有其他方法来实现该功能,但这里使用这种方法是为了明确所涉及的步骤,特别是当创建了像 caps"a $b c ${d*e}" 这样的插值器时,需要使用两个迭代器来重建字符串。

讨论

理解解决方案有助于理解字符串插值的工作原理,换句话说就是有助于理解在IDE中输入的Scala代码是如何转换为其他Scala代码的。通过字符串插值,你的方法的调用者会写出这样的代码:

    id"text0${expr1}text1 ... ${exprN}textN"

在这段代码里:

  • id是字符串插值方法的名称,在前面的例子中是 caps

  • textN 片段是输入(预插值)字符串中的字符串常量。

  • exprN 片段是输入字符串中用 $expr${expr} 语法编写的表达式。

编译时,编译器将这段代码翻译成类似下面这样的代码:

    StringContext("text0", "text1", ..., "textN").id(expr1, ..., exprN)

如上所示,字符串的常量部分也就是字符串的字面量会被提取出来作为参数传递给 StringContext 构造函数。包含在初始字符串中的所有表达式会被作为参数传递给 StringContext 实例的 id 方法,对于前一个例子的就是 caps 方法。

下面来看一个具体的例子,假设有一个名为 yo 的插值器和这段代码:

    val b = "b"
    val d = "d"
    yo"a $b c $d"

在编译阶段的第一步,最后一行被转换为这样:

    val listOfFruits = StringContext("a ", " c ", "").yo(b, d)

现在, yo 方法需要像解决方案中所示的 caps 方法那样处理这两个迭代器:

    args.iterators contains:  "a ", " c ", "" // String type
    exprs.iterators contains: b, d            // Any type

更多的插值器 -- TODO 鸽子图

关于更多的细节,在本书的GitHub( https://github.com/alvinj/ScalaCookbookV2Examples )项目中展示了几个插值器的例子,包括我的 Q 插值器,它可以对这种多行字符串输入进行转换:

    val fruits = Q""" 
        apples bananas cherries 
    """

得到这样一个结果:

    List("apples", "bananas", "cherries")

另见

  • 本小节使用了扩展方法,这将在8.9小节“用扩展方法为封闭类添加新方法”中讨论。

  • Scala 关于字符串插值器的官方文档( https://docs.scala-lang.org/overviews/core/string-interpolation.html )。

2.12 创建随机字符串

问题

当你使用 Random 类的 nextString 方法生成一个随机字符串时,会看到很多不寻常的输出或 ? 字符。一个典型的例子是这样的:

    scala> val r = scala.util.Random() 
    val r: scala.util.Random = scala.util.Random@360d41d0

    scala> r.nextString(10) 
    res0: String = ??????????

解决方案

因为 nextString 返回Unicode字符,这些字符在你的系统上可能显示得很好,也可能不显示。要想只生成字母数字字符,即字母[A-Za-z]和数字[0-9],请使用下面的方式:

    import scala.util.Random
    // examples of two random alphanumeric strings 
    Random.alphanumeric.take(10).mkString // 7qowB9jjPt
    Random.alphanumeric.take(10).mkString // a0WylvJKmX

Random.alphanumeric 返回一个 LazyList ,所以这里使用 take(10).mkString 来获取流中的前十个字符。如果只调用 Random.alphanumeric.take(10) ,会得到这个结果:

    Random.alphanumeric.take(10) // LazyList[Char] = LazyList(<not computed>)

因为 LazyList 是惰性的 —— 它只在需要的时候才会被计算--所以必须调用像 mkString 这样的方法来对其求值。

讨论

根据 Random 文档( https://oreil.ly/syhlb ),alphanumeric“返回一个从A-Z、a-z和0-9中平均选择的伪随机的 LazyList” 。

如果想要一个更广范围的字符,nextPrintableChar 方法会返回ASCII字符,其ASCII码的范围是33-126。这包括键盘上几乎所有的简单字符,包括字母、数字和像!、-、+、]和>这样的字符。例如,这里有一个小算法,可以生成一个随机长度的可打印字符序列:

    val r = scala.util.Random 
    val randomSeq = for i <- 0 to r.nextInt(10) yield r.nextPrintableChar

下面是该算法所产生的随机序列的几个例子:

    Vector(s, `, t, e, o, e, r, {, S) 
    Vector(X, i, M, ., H, x, h) 
    Vector(f, V, +, v)

这些可以用 mkString 转换为一个 字符串 ,如下所示:

    randomSeq.mkString  // @Wvz#y#Rj\
    randomSeq.mkString  //b0F:P&!WT$

请参阅asciitable.com( https://www.asciitable.com/ )或类似网站,了解ASCII范围内33-126的完整字符列表。

惰性方法

正如20.1小节“Spark入门”中所描述的,在Apache Spark中,可以把集合方法看成是转换方法(transformation)或动作方法(action):

  • 转换方法 可以转换一个集合中的元素。对于像 ListVectorLazyList 这样的不可变的类,这些方法会对现有的元素进行转换,从而创建一个新的集合。就像Spark一样,当你使用Scala的Lazy List 时,这些方法被惰性求值(也被称为惰性或非严格求值)。像 mapfiltertake 等等方法都被认为是转换方法。

  • 动作方法 是本质上是强制求值的方法。它们是一种声明“我现在就要这个结果”的方式。像 foreachmkString 这样的方法可以被认为是动作方法。

请参阅11.1小节“选择合适的集合类”以了解更多关于转换方法的讨论和例子。

另见

  • 在我的博客“How to create random strings in Scala (a few different examples)”( https://oreil.ly/PEijH )中,展示了七种不同生成随机字符串的方法,包括纯字母和字母数字字符串。

  • 在 “Scala: a function to generate a random-length string with blank spaces”( https://oreil.ly/LtUy4 )中,展示了如何生成一个包含空格的随机长度的随机字符串。

最后更新于

这有帮助吗?