Scala 方法的定义声明

2019-05-01 17:05:35 | 编辑 | 添加

1.使用默认值定义方法

前面已经介绍过case class,这次就用起来作为案例,定义Point 类,并提供默认的初始化值,定义新的shift 方法,用于从现有的Point 对象中对“点”进行平移,从而创建一个新的Point 对象。它使用了copy 方法,copy 方法也是case 类自动创建的。

case class Point(x: Double = 0.0, y: Double = 0.0) {
def shift(deltax: Double = 0.0, deltay: Double = 0.0) = copy (x + deltax, y + deltay)
}


2.使用命名参数列表

copy 方法允许你在创建case 类的新实例时,只给出与原对象不同部分的参数,这一点对于大一些的case 类非常有用:

scala> val p1 = new Point(x = 3.3, y = 4.4) // 显式使用命名参数列表。
p1: Point = Point(3.3,4.4)
scala> val p2 = p1.copy(y = 6.6) // 指定新的y值,创建新实例。
p2: Point = Point(3.3,6.6)

命名参数列表让客户端代码更具可读性。当参数列表很长,且有若干参数是同一类型时,bug 容易避免,因为在这种情况下很容易搞错参数传入的顺序。当然,更好的做法是一开始就避免出现过长的参数列表。


3.定义方法具有多个参数列表

abstract class Shape() {
/**
* draw 带两个参数列表,其中一个参数列表带着一个表示绘制偏移量的参数
* 另一个参数列表是我们之前用过的函数参数。
*/
def draw(offset: Point = Point(0.0, 0.0))(f: String => Unit): Unit =
f(s"draw(offset = $offset), ${this.toString}")
}

这里的draw 方法有两个参数列表,这个拥有两个List参数是不一样的,第一个参数列表的参数允许你指定Point 对象,第二个参数列表是用来绘制所用的函数的函数参数

你可以任意指定参数列表的个数,但实际上很少有人使用两个以上的参数列表。


3.1优点一:更优雅

那么,为什么要允许多个参数列表呢?当最后一个参数列表只包含一个表示函数的参数时,多个参数列表的形式拥有整齐的块结构语法。以下是我们调用新的draw 方法的表达方式:

s.draw(Point(1.0, 2.0))(str => println(s"ShapesDrawingActor: $str"))

Scala 允许我们把参数列表两边的圆括号替换为花括号,因此,这一行代码还可以写为:

s.draw(Point(1.0, 2.0)){str => println(s"ShapesDrawingActor: $str")}

如果函数字面量不能在一行内完成,我们可以重写为以下方式:

s.draw(Point(1.0, 2.0)) { str =>
println(s"ShapesDrawingActor: $str")
}

或写为等价的形式:

s.draw(Point(1.0, 2.0)) {
str => println(s"ShapesDrawingActor: $str")
}

这一写法很像我们之前常用来写if 和for 表达式或方法体的代码块。只不过,在这里的{…} 块所表示的函数是我们要传递给draw 方法的参数

当函数字面量很长时,这种用{…} 代替(…) 的“语法糖”使得代码看起来美观多了。此时的代码更像我们所熟悉和喜爱的块结构语法。如果我们使用缺省的偏移量,第一个圆括号就不能省略:

s.draw() {
str => println(s"ShapesDrawingActor: $str")
}

如同Java 方法一样,draw 方法也可以只使用一个带两个参数值的参数列表。如果那样,客户端代码就会像这样写:

s.draw(Point(1.0, 2.0),
str => println(s"ShapesDrawingActor: $str")
)

这份代码并没那么清晰和优雅。使用默认值开启offset 也没那么便捷,因此我们不得不对参数进行命名:

s.draw(f = str => println(s"ShapesDrawingActor: $str"))


3.2 第二个优势是在之后的参数列表中进行类型推断。如以下例子:

scala> def m1[A](a: A, f: A => String) = f(a)
m1: [A](a: A, f: A => String)String
scala> def m2[A](a: A)(f: A => String) = f(a)
m2: [A](a: A)(f: A => String)String
scala> m1(100, i => s"$i + $i")
<console>:12: error: missing parameter type
m1(100, i => s"$i + $i")
scala> m2(100)(i => s"$i + $i")
res0: String = 100 + 100

函数m1 和函数m2 看起来几乎一模一样,只不过m1是两个参数,而m2有两个参数列表,但我们需要注意用相同的参数调用它们时m1 和m2 的表现。我们传入Int 和一个函数Int => String,对于m1,Scala 无法推断该函数的参数i,m2 则可以。


3.3使用多个参数列表的第三个优势是,我们可以用最后一个参数列表来推断隐含参数。隐含参数是用implicit 关键字声明的参数。当相应方法被调用时,我们可以显式指定这个参数,或者也可以不指定,这时编译器会在当前作用域中找到一个合适的值作为参数。隐含参数可以代替参数默认值,而且更加灵活。


4.可变长参数值

println()这样的方法可接受变长参数值。可以传递零个、一个或者多个参数值给这样的方法。在Scala 中,你可以方便地创建接受变长参数值的函数。我们可以设计接受变长参数值的方法。但是,如果我们有多个参数,那么只有最后一个参数可以接受变长参数值。我们可以在最后一个参数类型后面加上星号,以表明该参数(parameter)可以接受可变长度的参数值(argument)。下面以函数max()为例。

def max(values: Int*) = values.foldLeft(values(0)) { Math.max }

调用 max()函数的示例如下所示。

max(8, 2, 3)

在上面的例子中,我们只给函数传递了 3 个参数值,我们也可以传递更多参数值。下面看一个例子

max(2, 5, 3, 7, 1, 6)

当参数的类型使用一个尾随的星号声明时,Scala 会将参数定义成该类型的数组。让我们用下面的例子来进行验证

def function(input: Int*): Unit = println(input.getClass)
function(1, 2, 3)

可以用任意数量的参数调用 max()函数。因为参数类型是数组,所以我们可以使用迭代器来处理接收到的参数的集合。将数组而非离散值作为参数值传入好像很吸引人,但是并不能这样做。例如,下面这个例子:

val numbers = Array(2, 5, 3, 7, 1, 6)
max(numbers) // 类型匹配错误

上面的代码将会产生如下编译错误:

CantSendArray.scala:5: error: type mismatch;
found : Array[Int]
required: Int
max(numbers) // 类型匹配错误
one error found


类型不兼容是导致这个错误的主要原因。这个参数很像数组,但不是字面上的数组类型,而参数值现在是一个数组。然而,如果我们有一组值,那么我们更希望直接传递数组。我们

可以使用数组展开标记(array explode notation),像这样:

val numbers = Array(2, 5, 3, 7, 1, 6)
max(numbers: _*)

参数名后面的一系列符号告诉编译器将数组展开成所需的形式,以传送变长参数值。

现在我们已经知道了如何将变长参数值传递给方法。


5.嵌套方法的定义与递归

方法的定义还可以嵌套。当你将一个很长的方法重构为几个更小的方法时,如果这些小的辅助方法只在该方法中调用,就可以用嵌套方法。我们将这些辅助函数嵌套定义在原方法中,它们便对其他外层的代码不可见,包括类中的其他方法。以下代码实现了阶乘的计算,在这个方法中,我们调用了另一个嵌套的方法去完成阶乘的实际计算:

// src/main/scala/progscala2/typelessdomore/factorial.sc
def factorial(i: Int): Long = {
def fact(i: Int, accumulator: Int): Long = {
if (i <= 1) accumulator
else fact(i - 1, i * accumulator)
}
fact(i, 1)
}
(0 to 5) foreach ( i => println(factorial(i)) )

以下为代码运行的输出:

1
1
2
6
24
120

辅助函数递归地调用它本身,并传入一个accumulator 参数,阶乘的计算结果保存在该参数中。注意,当计数器i 达到1 时,我们就将阶乘的计算结果返回。(这里我们不考虑负整数参数,负整数的输入是无效的,本函数在i <= 1 时返回1。)定义好嵌套的方法后,factorial 调用该方法,传入参数i,并累计乘法的初始值1。

很容易忘记调用嵌套的函数!如果编译器提示,能找到Unit 但找不到Long,可能就是因为你忘记调用嵌套函数了。


是否注意到,我们两次用i 作为参数名?第一次是factorial 方法的参数,第二次是嵌套的fact 方法的参数。在fact 方法中使用的i 参数“屏蔽”了外部factorial 方法的i 参数。这样做是允许的,因为我们在fact 方法中并不需要外部的i,我们只在factorial 结尾调用fact 的时候才需要它。类似方法中声明的局部变量,嵌套的方法也只在声明它的方法中可见。观察这两个方法的返回值。因为阶乘的计算结果增长非常快,我们选择使用Long 类型,而不使用Scala 自动推断的Int 类型。如果使用Int 类型,factorial 就不需要上述的类型注释了。然而,我们必须要为fact 声明返回类型。因为这是一个递归方法,Scala 采用的是局部作用域类型推断,无法推断出递归函数的返回类型。


对递归函数你也许会感到一丝不安。我们是否在冒风险? JVM 和许多其他语言环境并不对尾递归做优化,否则尾递归会将递归转为循环,可以避免栈溢出。(尾递归一词,表示调用递归函数是该函数中最后一个表达式,该表达式的返回值就是所调用的递归函数的返回值。)


递归是函数式编程的特点,也是优雅地实现很多算法的强大工具。所以,Scala 编译器对尾递归做了有限的优化。它会对函数调用自身做优化,但不会优化所谓的trampoline 的情况,也就是“a 调用b 调用a 调用b”的情形。


你可能仍然想知道自己写的尾递归是否正确,编译器是否对自己的尾递归执行了优化。没有人希望在生产环境中出现栈空间崩溃。幸运的是,如果你加一个tailrec 关键字(http://www.scala-lang.org/api/current/#scala.annotation.tailrec),编译器会告诉你代码是否正确地实现了尾递归,如以下factorial 的改良版本:

// src/main/scala/progscala2/typelessdomore/factorial-tailrec.sc
import scala.annotation.tailrec
def factorial(i: Int): Long = {
@tailrec
def fact(i: Int, accumulator: Int): Long = {
if (i <= 1) accumulator
else fact(i - 1, i * accumulator)
}
fact(i, 1)
}
(0 to 5) foreach ( i => println(factorial(i)) )


如果fact 不是尾递归,编译器就会抛出错误。我们用这个特性在REPL 中写出递归的Fibonacci 函数:

scala> import scala.annotation.tailrec
scala> @tailrec
| def fibonacci(i: Int): Long = {
| if (i <= 1) 1L
| else fibonacci(i - 2) + fibonacci(i - 1)
| }
<console>:16: error: could not optimize @tailrec annotated method fibonacci:
it contains a recursive call not in tail position
else fibonacci(i - 2) + fibonacci(i - 1)

我们有两个递归调用,然后又对调用的结果做计算,而不是只在结尾调用一次递归函数,因此这个函数不是尾递归的。

最后要说明的是,外层方法所在作用域中的一切在嵌套方法中都是可见的,包括传递给外层方法的参数。