Skip to content

Latest commit

 

History

History
1113 lines (763 loc) · 30.4 KB

ch03.md

File metadata and controls

1113 lines (763 loc) · 30.4 KB

第3章:Scala基础

for (i <- Range.inclusive(1, 100)) {
  println(
    if (i % 3 == 0 && i % 5 == 0) "FizzBuzz"
    else if (i % 3 == 0) "Fizz"
    else if (i % 5 == 0) "Buzz"
    else i
  )
}

代码片段3.1:用Scala实现流行的"FizzBuzz"的编程挑战

本章将带你快速浏览Scala语言。从现在开始,我们将专注于Scala的基础,你也许会发现这些基础和任何一门主流编程语言都类似。

本章的目标就是让你能够熟练得像你惯用的其他主流编程语言一样,毫无障碍地编写一些Scala代码。本章并不会涵盖任何Scala特定的编程风格或语言特性:这些会放到第五章《耳目一新的Scala特性》中去。


本章的示例代码我们将使用Ammonite Scala REPL来编写:

$ amm
Loading...
Welcome to the Ammonite Repl 2.0.4 (Scala 2.13.1 Java 11.0.2)
@

3.1 数据类型

3.1.1 基本数据类型

<title>Title</title>
类型 值范围
Byte -128127
Short -32,76832,767
Int -2,147,483,6482,147,483,647
Long -9,223,372,036,854,775,8089,223,372,036,854,775,807
类型 值范围
Boolean true, false
Char 'a', '0', 'Z', '包', ...
Float 32位的浮点型
Double 64位的浮点型

这些类型和Java的基本数据类型是等价的,并且对于像C#,C++或其他静态类型的编程语言来说都是类似的。 你可以在控制台输入这些类型得到相应的输出结果。每一种类型都支持经典的操作,例如布尔类型支持逻辑运算符||&&,数值类型支持四则运算符 + - * /,位运算支持 |&等等。所有的值都支持用==来判断是否相等,用!=来判断不相等。

3.1.1.1 布尔类型

@ true || false
res0: Boolean = true

@ true && false
res1: Boolean = false

3.1.1.1 数值类型

默认使用的是32位的Int类型:

@ 1 + 2
res2: Int = 3

算术运算符的优先级和其他其他编程语言一样:*/+-优先级更高,并且括号可以用来分组。

@ 1 + 2 * 3
res3: Int = 7

@ (1 + 2) * 3
res4: Int = 9

Int类型是带符号的,而且当溢出时,值又会绕回从最小值开始:

@ 2147483647
res5: Int = 2147483647

@ 2147483647 + 1
res6: Int = -2147483648

你可以通过使用大写的L语法来声明数字为64位的长整型,那么就不这么容易出现溢出了:

@ 2147483647L
res7: Long = 2147483647L

@ 2147483647L + 1L
res8: Long = 2147483648L

除了基本操作外,java.lang.Integerjava.lang.Long类还拥有很多有用的方法:

@ java.lang.Integer.<tab>
BYTES                    decode                   numberOfTrailingZeros
signum                   MAX_VALUE                divideUnsigned
parseInt                 sum                      MIN_VALUE
getInteger               parseUnsignedInt         toBinaryString
...

@ java.lang.Integer.toBinaryString(123)
res9: String = "1111011"

@ java.lang.Integer.numberOfTrailingZeros(24)
res10: Int = 3

在整数后面使用.0的语法,指定使用64位的浮点型,并且它也拥有(和上面整形类型)类似的算术运算操作:

@ 1.0 / 3.0
res12: Double = 0.3333333333333333

你也可以在整数后面使用.0F的语法,要求使用32位的浮点型:

@ 1.0F / 3.0F
res13: Float = 0.33333334F

3.1.2 字符串

在Scala中,字符串是由每个16位的字符组成的数组:

@ "hello world"
res14: String = "hello world"

字符串可以由.substring方法进行切片:

@ "hello world".substring(0, 5)
res15: String = "hello"

@ "hello world".substring(5, 10)
res16: String = " worl"

构造字符串时可以使用+来拼接,或者通过使用字母前缀s"..."的字符串插值来实现,此时可以使用$${...}来替换要插入的变量。

@ val s = "hello" + 1 + " " + "world" + 2
s: String = "hello1 world2"

@ val x = 1; val y = 2

@ val s = s"Hello $x World $y"
s: String = "Hello 1 World 2"

@ val s = s"Hello ${x + y} World ${x - y}"
s: String = "Hello 3 World -1"

3.1.3 局部常量和变量

你可以用val关键字来命名一个局部的常量:

@ val x = 1

@ x + 2
res25: Int = 3

注意val标记的值是不可变的:当赋值后,你无法再对该x赋上另外一个值:

@ x = 3
cmd41.sc:1: reassignment to val
val res26 = x = 3
              ^
Compilation Failed

如果想要一个局部变量重新赋值,你必须使用var关键字。

@ var y = 1

@ y + 2
res27: Int = 3

@ y = 3

@ y + 2
res29: Int = 5

通常来说,你应该尽可能的使用val:因为在程序中大多值是不需要被再次赋值的,使用val能防止一些因偶然再次赋值而造成的错误。当且仅当你确定需要再次赋值时,才使用var

valvar关键字都可以显式地声明类型:

@ val x: Int = 1

@ var s: String = "Hello"
s: String = "Hello"

@ s = "World"

这样做可使代码文档化,以便能帮助人们更好理解你的代码。同样,当你不小心给一个变量赋值错误的类型时,这样的写法能让编译器帮助你捕捉到错误。

@ val z: Int = "Hello"
cmd33.sc:1: type mismatch;
 found   : String("Hello")
 required: Int
val z: Int = "Hello"
             ^
Compilation Failed

3.1.4 Tuples

Tuple 是一种有着定长的值的集合,其中每个值可以有不同的类型:

@ val t = (1, true, "hello")
t: (Int, Boolean, String) = (1, true, "hello")

@ t._1
res34: Int = 1

@ t._2
res35: Boolean = true

@ t._3
res36: String = "hello"

上述示例我们用(a, b, c)的语法将一个tuple保存到了局部变量t中,接着使用._1._2._3从中将值依次取出。tuple中的字段是不可变的。

@ val t: (Int, Boolean, String) = (1, true, "hello")

你也可以使用val (a, b, c) = t的语法,一次性将所有的值都提取出来,并给他们赋予一个有意义的名称:

@ val (a, b, c) = t
a: Int = 1
b: Boolean = true
c: String = "hello"

@ a
res39: Int = 1

@ b
res40: Boolean = true

@ c
res41: String = "hello"

Tuple的字段数最多不能超过22个:

@ val t = (1, true, "hello", 'c', 0.2, 0.5f, 12345678912345L)
t: (Int, Boolean, String, Char, Double, Float, Long) = (
  1,
  true,
  "hello",
  'c',
  0.2,
  0.5F,
  12345678912345L
)

多数Tuple相对比较小。大的Tuple容易让人困惑:当至少操作._1._2._3也行还行, 但是当用到._11._13这样的大Tuple时,就很容易把字段搞混了。 如果你正好要处理大的Tuple,考虑定义一个Class(3.4.1节)或者我们将会在第五章的《耳目一新的Scala特性》介绍的Case Class

3.1.5 数组

我们可以使用Array[T](a, b, c)语法来定义一个数组,并且使用a(n)来访问数组每个元素。

@ val a = Array[Int](1, 2, 3, 4)

@ a(0) // 取数组的第一个元素(数组下标是从0开始)
res44: Int = 1

@ a(3) // 去最后一个元素
res45: Int = 4

@ val a2 = Array[String]("one", "two", "three", "four")
a2: Array[String] = Array("one", "two", "three", "four")

@ a2(1) // 去第二个元素
res47: String = "two"

方括号内的类型参数[Int]或者[String]决定了数组的类型,与此同时圆括号内的(1, 2, 3, 4)决定了数组初始化的内容。需要注意的是,通过下标索引访问数组中的元素是通过圆括号a(3)而不是方括号a[3],这一点与其他编程语言的约定有所不同。

你也可以忽略掉显式的类型参数,由编译器来推断数组的类型:

@ val a = Array(1, 2, 3, 4)
a: Array[Int] = Array(1, 2, 3, 4)

@ val a2 = Array("one", "two", "three", "four")
a2: Array[String] = Array("one", "two", "three", "four")

或使用new Array(T)[length]来定义一个(固定长度的)空的数组,随后在通过下标索引进行赋值:

@ val a = new Array[Int](4)
a: Array[Int] = Array(0, 0, 0, 0)

@ a(0) = 1

@ a(2) = 100

@ a
res53: Array[Int] = Array(1, 0, 100, 0)

数组是一直可变但长度固定的数据结构:你可以改变每个下标所应的元素,但是不能通过增加或者删除里面的元素来改变数组的大小。 稍后我们将在第4章《Scala集合》中看到如何创建可变长度的集合类型。

使用new Array创建出来的数组,对于数值类型来说,所有的元素默认值都是0,对于布尔类型来说,所有元素的默认值是false,对于字符串或其他类型来说,所有元素的默认值都是null。

Scala同样支持多维数组(也称数组的数组):

@ val multi = Array(Array(1, 2), Array(3, 4))
multi: Array[Array[Int]] = Array(Array(1, 2), Array(3, 4))

@ multi(0)(0)
res55: Int = 1

@ multi(0)(1)
res56: Int = 2

@ multi(1)(0)
res57: Int = 3

@ multi(1)(1)
res58: Int = 4

多维数组非常适合于表示网格,矩阵等相似类型的数据结构。

3.1.6 Options

Scala的Option[T]类型允许你去表示一个可有可无的值。一个Option[T]要么以使用Some(v: T)来表示值存在,要么使用None来表示值不存在。

@ def hello(title: String, firstName: String, lastNameOpt: Option[String]) = {
    lastNameOpt match{
      case Some(lastName) => println(s"Hello Mr. $lastName")
      case None => println(s"Hello $firstName")
    }
  }

@ hello("Mr", "Haoyi", None)
Hello Haoyi

@ hello("Mr", "Haoyi", Some("Li"))
Hello Mr. Li

上面的例子向你展示了如果使用SomeNone去构建一个Option,同时也演示了如何使用模式匹配来处理Option。Scala的许多API依靠的就是Option而不是null来判断一个值是否存在。一般说来,Option会强迫你去处理存在或不存在的情况,而使用null的时候却恰恰容易忘记这个值究竟是不是非空的,结果在运行期导致了令人困惑的NullPointerException异常发生。 我们会在第5章《耳目一新的Scala特性》深入模式匹配。

Option包含了一些有用的方法,这些方法使它能够轻松地和可选值配合使用,比如getOrElse方法就允许在Option是None的时候,用一个可选值来代替:

@ Some("Li").getOrElse("<unknown>")
res0: String = "Li"

@ None.getOrElse("<unknown>")
res1: String = "<unknown>"

Option和一个大小为0或者1的集合非常像,你可以像遍历集合那样的对它进行操作:

@ def hello2(nameOpt: Option[String]) = {
    for (name <- nameOpt) println(s"Hello $name")
  }

@ hello2(None) // 什么都不做

@ hello2(Some("Haoyi"))
Hello Haoyi

或者使用标准的集合操作方法来对其进行转换,比如使用.map

@ def lengthOfName(nameOpt: Option[String]) = nameOpt.map(_.length).getOrElse(-1)

@ lengthOfName(Some("Haoyi"))
res6: Int = 5

@ lengthOfName(None)
res7: Int = -1

上面的示例中,我们将.map.getOrElse组合在一起使用,如果输入的字符串存在,则输出字符串的长度,否则输出-1。 在第四章《Scala集合》中我们将学习更多关于Scala集合的操作。

3.2 循环,条件和解析

3.2.1 For循环

Scala中的For循环和其他语言的foreach类似: 不需要显式的维护一个自增的下标索引,而是直接依次遍历集合中的每个元素:

@ var total = 0; val items = Array(1, 10, 100, 1000)

@ for (item <- items) total += item

@ total
res79: Int = 1111

如果你想要循环一个索引范围,可以想这样使用Range(0, 5),从0遍历到5:

@ var total = 0

@ for (i <- Range(0, 5)) {
    println("Looping " + i)
    total = total + i
  }
Looping 0
Looping 1
Looping 2
Looping 3
Looping 4

@ total
res64: Int = 10

你可以在循环头部使用multiple <-s,来循环一个内嵌数组:

@ val multi = Array(Array(1, 2, 3), Array(4, 5, 6))

@ for (arr <- multi; i <- arr) println(i)
1
2
3
4
5
6

循环内还能使用if语句进行过滤:

@ for (arr <- multi; i <- arr; if i % 2 == 0) println(i)
2
4
6

3.2.2 If-Else

if-else条件判断和其他编程语言是类似的:

@ var total = 0

@ for (i <- Range(0, 10)) {
    if (i % 2 == 0) {
      total += i
    } else {
      total += 2
    }
  }

@ total
res93: Int = 30

在Scala中,if-else也可以实现类似其他语言的三目运算。因此,你也可以按照下面的写法来实现上面的代码:

@ var total = 0

@ for (i <- Range(0, 10)) {
    total += (if (i % 2 == 0) i else 2)
  }

@ total
res97: Int = 30

3.2.3 Fizzbuzz

现在我们知道了Scala的语法基础,那么来挑战一下这个经典的Fizzbuzz的算法题吧:

写一个小程序,从1至100每行输出当前的数字编号。 如果是3的倍数,则用Fizz代替数字输出。 如果是5的倍数,则用Buzz代替数字输出。 如果既是3的倍数也是5的倍数,则用FizzBuzz代替数字输出。

我们可以用以下程序达成:

@ for (i <- Range.inclusive(1, 100)) {
    if (i % 3 == 0 && i % 5 == 0) println("FizzBuzz")
    else if (i % 3 == 0) println("Fizz")
    else if (i % 5 == 0) println("Buzz")
    else println(i)
  }
1
2
Fizz
4
Buzz
Fizz
7
8
Fizz
Buzz
11
Fizz
13
14
FizzBuzz
...

由于if-else就是一个表达式,我们还可以这样来编写代码:

@ for (i <- Range.inclusive(1, 100)) {
    println(
      if (i % 3 == 0 && i % 5 == 0) "FizzBuzz"
      else if (i % 3 == 0) "Fizz"
      else if (i % 5 == 0) "Buzz"
      else i
    )
  }

3.2.4 解析

除了使用for来定义循环处理逻辑外,你还可以将它和yield一起使用,将一个集合转换成一个新的集合:

@ val a = Array(1, 2, 3, 4)

@ val a2 = for (i <- a) yield i * i
a2: Array[Int] = Array(1, 4, 9, 16)

@ val a3 = for (i <- a) yield "hello " + i
a3: Array[String] = Array("hello 1", "hello 2", "hello 3", "hello 4")

你可以在for循环的括号内使用if表达式过滤元素,再得到最终的集合:

@ val a4 = for (i <- a if i % 2 == 0) yield "hello " + i
a4: Array[String] = Array("hello 2", "hello 4")

或者对两个输入数组(如果下所述的ab)使用解析,使之扁平化变成一个输出数组,这和之前提到的内嵌for循环相似:

@ val a = Array(1, 2); val b = Array("hello", "world")

@ val flattened = for (i <- a; s <- b) yield s + i
flattened: Array[String] = Array("hello1", "world1", "hello2", "world2")

为了更好的可读性,如果你想让子循环跨多行编写,你还可以用花括号{}来代替圆括号()。注意<-s的顺序是影响解析结果的,下面的示例就演示了子循环顺序不同而产生的结果也不同的情况:

@ val flattened = for{
    i <- a
    s <- b
  } yield s + i
flattened: Array[String] = Array("hello1", "world1", "hello2", "world2")

@ val flattened2 = for{
    s <- b
    i <- a
  } yield s + i
flattened2: Array[String] = Array("hello1", "hello2", "world1", "world2")

我们可以使用解析来编写一个新的FizzBuzz版本,它并不会立即把结果输出到控制台,而是将结果返回给一个Seq(序列的简称)对象:

@ val fizzbuzz = for (i <- Range.inclusive(1, 100)) yield {
    if (i % 3 == 0 && i % 5 == 0) "FizzBuzz"
    else if (i % 3 == 0) "Fizz"
    else if (i % 5 == 0) "Buzz"
    else i.toString
  }
fizzbuzz: IndexedSeq[String] = Vector(
  "1",
  "2",
  "Fizz",
  "4",
  "Buzz",
...

我们接下来可以对这个fizzbuzz集合进行想要的操作:比如保存到一个变量里,将其传给一个方法,或者用其他方式进行传递。我们将会在第四章《Scala的集合》中再详细地向你介绍这些集合的用法。

3.3 方法和函数

3.3.1 方法

你可以使用关键字def来定义方法:

@ def printHello(times: Int) = {
    println("hello " + times)
  }

@ printHello(1)
hello 1

@ printHello(2)
hello 2

@ printHello(times = 3) // 显式指定参数名称
hello 3

传递一个错误类型的参数或者缺少必要参数会引起编译错误:

@ printHello2("1") // 错误类型的参数
cmd128.sc:1: type mismatch;
 found   : String("1")
 required: Int
val res128 = printHello2("1")
                         ^
Compilation Failed


@ printHello() // 缺少参数
cmd121.sc:1: not enough arguments for method printHello: (times: Int)Unit.
Unspecified value parameter times.
val res121 = printHello()
                       ^
Compilation Failed

参数是可以有默认值的,这样的参数就不是必须要传的:

@ def printHello2(times: Int = 0) = {
    println("hello " + times)
  }

@ printHello2(1)
hello 1

@ printHello2()
hello 0

3.3.1.1 方法的返回值

方法除了打印外,还能返回执行结果:

@ def hello(i: Int = 0) = {
    "hello " + i
  }

@ hello(1)
res125: String = "hello 1"

@ hello()
res126: String = "hello 0"

通常来说在Scala的方法中,花括号{}里最后一行的表达式被当做是返回值。你可以调用方法将结果输出或者返回结果,再由其他程序计算:

@ println(hello())
hello 0

@ val helloHello = hello(123) + " " + hello(456)
helloHello: String = "hello 123 hello 456"

@ helloHello.reverse
res17: String = "654 olleh 321 olleh"

3.3.2 函数值

你可以使用=>语法来顶一个函数值:

@ val f: Int => Int = i => i + 1

@ f(1)
res19: Int = 2

@ f(2)
res20: Int = 3

从调用来看函数值和方法类似,通过传参调用执行一些操作或返回某个结果。与方法不同的是,函数本身就是值:你可以将其任意传递或暂存到变量中,稍后用得到的时候在执行。

@ var g: Int => Int = i => i + 1

@ g(10)
res25: Int = 11

@ g = i => i * 2

@ g(10)
res27: Int = 20

需要注意的和方法不同是,函数值不能拥有可选的参数(比如说默认参数)并且也不能接受类型参数。当一个方法被转成函数值时,任何的可选参数都必须显式的包含或排除(比如说,使用了占位符_语法) 而且类型参数要能固定成实际的类型。函数值是匿名的,和方法执行相比,函数使得堆栈追踪信息变得不是那么的容易阅读。

一般来说,你应当多使用方法,除非真的是有需求要将函数当做参数或保存在变量中。但是如果你需要的是灵活度,那函数值是值得拥有的强大工具。

3.3.2.1 将函数当成方法参数

一种比较常见地用法是将函数值当成参数传递到接收这个函数类型的方法。比如说下面示例代码定义的update方法:

@ class Box(var x: Int) {
    def update(f: Int => Int) = x = f(x)
    def printMsg(msg: String) = println(msg + x)
  }

我们定义了一个Box类,它有一个printMsg方法将其内容输出(一个整数数字),同时还有一个接收参数类型是Int => Int的方法update用来更新内部变量x的值。 你可以通过传递一个函数的实现到update方法中,从而对变量x进行修改:

@ val b = new Box(1)

@ b.printMsg("Hello")
Hello1

@ b.update(i => i + 5)

@ b.printMsg("Hello")
Hello6

其中.update调用可以被简写成如下代码:

@ b.update(_ + 5)

@ b.printMsg("Hello")
Hello11

最后一个例子展示了即使是简单得形如i => i + 5这样的函数,也可以简写成_ + 5,其中下划线_在这代表的是函数的参数。在多个参数的函数中也是可以的,比如(x ,y) => x + y可以写成_ + _

任何一个接收函数参数的方法也可以传递给另外一个方法,只要这个方法的签名和函数类型相匹配,像下面的Int => Int

@ def increment(i: Int) = i + 1

@ val b = new Box(123)

@ b.update(increment)

@ b.update(x => increment(x)) //显式的用lambda来调用

@ b.update(increment(_)) // 你也可以使用下划线`_`的占位符语法

@ b.printMsg("result: ")
result: 126

这个可以将函数当成参数传递给方法的能力被广泛的运用于标准库中,例如实现了简洁的集合转换操作。我们会在第四章《Scala集合》中看到更多用法。

3.4 类和特质

3.4.1 类

你可以使用关键字class来定义类,然后使用关键字new来实例化它们:

@ class Foo(x: Int) {
    def printMsg(msg: String) = println(msg + x)
  }

@ val f = new Foo(1)

@ f.printMsg("hello")
hello1

@ f.printMsg("world")
world1

默认情况下,类的构造函数定义的变量的作用域对该类的所有方法都是可用的:这个上面代码的(x: Int)既定义了私有属性,同时也定义了构造函数。属性x于是就可以在函数printMsg被访问,但是不能再类的外部被访问:

@ f.x
cmd132.sc:1: value x is not a member of ammonite.$sess.cmd128.Foo
val res132 = f.x
               ^
Compilation Failed

当你加上val来定义它时,x就可以通过外部访问了:

@ class Bar(val x: Int) {
    def printMsg(msg: String) = println(msg + x)
  }

@ val b = new Bar(1)

@ b.x
res134: Int = 1

你还可以用var来定义它,这样还能对他进行修改:

@ class Qux(var x: Int) {
    def printMsg(msg: String) = {
      x += 1
      println(msg + x)
    }
  }

@ val q = new Qux(1)

@ q.printMsg("hello")
hello2

@ q.printMsg("hello")
hello3

当然你也可以在类的结构体内用valvar来定义属性来保存数据。这些都会在对象实例化的时候,会进行计算:

@ class Foo(x: Int) {
    val bangs = "!" * x
    def printMsg(msg: String) = println(msg + bangs)
  }

@ val f = new Foo(3)

@ f.printMsg("hello")
hello!!!


@ f.printMsg("world")
world!!!

3.4.2 特质

特质类似于传统面向对象语言的接口:它拥有一组方法,多个类继承它后,就能做到类的实例之间相互替换。

@ trait Point{ def hypotenuse: Double }

@ class Point2D(x: Double, y: Double) extends Point{
    def hypotenuse = math.sqrt(x * x + y * y)
  }

@ class Point3D(x: Double, y: Double, z: Double) extends Point{
    def hypotenuse = math.sqrt(x * x + y * y + z * z)
  }

@ val points: Array[Point] = Array(new Point2D(1, 2), new Point3D(4, 5, 6))

@ for (p <- points) println(p.hypotenuse)
2.23606797749979
8.774964387392123

以上,我们定义了一个带有单个方法def hypotenuse: Double特性Point。两个子类Point2DPoint3D各自的构造函数参数不同,但是都实现了方法hypotenuse,接下来我们把Point2DsPoint3Ds实例化的对象 放入到数组points: Array[Point]中,此时不管他们实际的类型是什么,他们都当成是拥有hypotenuse方法的对象。

3.5 结论

本章我们快速浏览了Scala语言的核心部分。尽管有些语法也许对你来说比较陌生,但是概念应该都是类似的:基本上每个编程语言都有基本数据类型,字符串,循环,条件语句,方法,函数,类和接口。 到现在我们已经涵盖了Scala语言的基础部分,接下来我们要看看Scala的标准类库的核心部分:Scala集合。