Scala 分支结构与自定义运算符

1,537 阅读8分钟

本章重点介绍 Scala 基础分支结构与运算符重载。

分支结构

可以将 Scala 的分支结构看成是特殊的表达式,因为它们都具备返回值。

选择分支

Scala 的选择分支具备返回值,因此它可以替代 Java 中的三目运算符 ? _ : _

val a : Int = if (Random.nextInt() % 2 == 0) 1 else 2 

同样,每个语句块内最后一行表达式的计算结果会自动作为返回值。顺带一提,Scala 是崇尚简洁的语言:如果语句块只有单个的表达式或值,那么 {} 花括号就是多余的。

switch 分支的替代

Scala 没有 switch 分支,取代而之的是模式匹配。比如:

a match {
  case 1 => println("result = 1")
  case 2 => println("result = 2")
  case _ => println("...")
}

每个 case 声明了一个分支,分支之间不会穿透,程序会按顺序选择最先满足的分支并直接返回。注意,如果模式匹配没有成功执行任何一个分支,则会抛出 MatchError 异常。case _ => 相当于默认分支,它总是会满足匹配的条件。所以,这个分支应该被放到最后执行。

暂时交代这么多。模式匹配的应用场景以及背后的设计思路远不止 switch 这么简单,我们放到后文去介绍。

for 表达式

在 Scala 中,for 表达式的实质是 函数式组合子的语法糖,见:Scala +:类型推断,列表操作与 for loop - 掘金 (juejin.cn)。这和其它语言中硬编码的 for 循环有本质的区别,语法风格也不同。

// 创建一个 Scala 定长数组。
val arr: Array[Int] = Array[Int](1,2,3,4,5)
for(i <- arr) println(i)

上述代码的 for 表达式表示:将 arr 数组的每个元素依次赋值给 i,然后执行语句块 println(i)

1. to 与 until

Scala 不使用三段式控制 for 循环。如果要基于一个整数序列循环遍历,利用 to 可以直接创建一个闭区间 [l,r],利用 until 可以直接创建一个左闭右开区间 [l,r)

for(i <- 1 to 10) println(i)    // 输出 1 到 10,等价于i=1; i<=10; i++
for(i <- 1 until 10) println(i) // 输出 1 到 9 ,等价于i=1; i<10; 1++

2. 根据数组下标迭代元素

Scala 的数组类型提供 indices 方法返回下标序列,可以直接利用它对数组进行下标形式的遍历。

//arr.indices获取的是数组的下标,而非元素。
for(index <- arr.indices) println(s"第${index}个位置:${arr(index)}")

indices 方法在底层是 0 until length 的包装,其中 length 是数组的长度。

3. 循环守卫

Scala 在 for 表达式内使用 if 声明的循环守卫实现 continue 效果,循环守卫可以有多个。只有所有循环守卫的判断结果均为 true,才执行内部的语句块。比如,输出 10 以内的偶数:

//输出0-10以内的偶数。
for(i <- 0 to 10 if i % 2 ==0) println(i)

4. 跨步迭代

使用 Range 可以构造出一个 [start,end) 左闭右开区间 ,步长为 step 的序列。

Range(start:Int,end:Int,step:Int)

比如,尝试生成 1 到 11 范围内的奇数:

//1,3,5,7,9,但不包含11。
for(i <- Range(1,10,2)) println(i)

5. 嵌套组合 for 表达式

我们偶尔会使用到嵌套的 for 循环来解决问题,以 Java 语言实现的冒泡排序为例。

int[] arr = {3,7,6,8,1,4,2};
for(int i = 0 ; i< arr.length; i++)
{
    for (int j = 0;j<arr.length-i-1; j++)
    {
        if (arr[j] > arr[j+1]){
            int temp = arr[j];
            arr[j] =arr[j+1];
            arr[j+1] = temp;
        }
    }
}

for (int i : arr){ System.out.println(i); }

下面是在 Scala 中的简化写法:

for (i <- arr.indices; j <- 0 until arr.length - i -1 if arr(j)>arr(j+1)) {
    val temp = arr(j)
    arr(j) = arr(j+1)
    arr(j+1) = temp
}

上述的代码还可以改写更易阅读的形式:

for {
    i <- arr.indices 
    j <- 0 until arr.length - i -1 
    if arr(j)>arr(j+1)
}{
    val temp = arr(j)
    arr(j) = arr(j+1)
    arr(j+1) = temp
}

6. yield

for 表达式可以使用 yield 关键字收集每次循环的返回值并最终生成一个序列。

// strings 是一个 immutable.IndexedSeq[Int] 类型。
val strings = for (i <- 1 to 10) yield i.toString

while / do-while ( 不推荐 )

这两类循环事实上很少使用。我们后期使用 Scala 解决问题的方式无非三种:

  1. 各种 flatMapmapfilter 这样的组合子实现的数据流处理 ( 参考 Java 8 的流操作 )。
  2. 上述各种组合子的等效 for 表达式。
  3. 通过函数组合以及模式匹配实现的递归。

while / do-while 是一个充满了 命令式编程 风格的分支结构,这其实是和 Scala 推崇的 声明式编程 相悖的。另一方面,如果要使用 while / do-while 循环,那么就不可避免地需要通过监视外部变量来控制循环何时结束,这种做法被认为是破坏了代码的纯粹性。类似的案例是:如果在 Java 中尝试用 lambda 表达式内去更改 外部的值,程序也会拒绝编译。

总而言之,Scala 中使用 while / do-while 并不是一个明智的做法。比如说:while 循环内的 break 功能是通过抛出异常的方式完成的。这引入了大量不必要又难以记忆的代码块。

import scala.util.control.Breaks._
// 中断的 while 需要放到 breakable 内执行。
// ()=>{} 可以类比 Java 的 Runnable。
breakable(() => {
  var i = 0
  while (i < 10) {
    if (i == 5) break()
    i += 1
  }
})

尤其在 Scala 3 版本中,do-while 被废弃了。见:Dropped: Do-While | Scala 3 Language Reference | Scala Documentation (scala-lang.org)

异常分支

回顾 Java 的两种异常,见:受检异常与非受检异常的区别 - 烟味i - 博客园 (cnblogs.com)

Java 异常有两种,分别是 受检异常 ( Checked Exception ) 和 非受检异常 ( Unchecked Exception )。

非受检异常指随着程序运行可能发生,又无法进行预料的错误,具体指 java.lang.RuntimeExceptionjava.lang.Error 这两大类。非受检异常不强制要求捕获,也不强制要求使用 throws 声明向上抛出。

java.lang.RuntimeException 指代由于逻辑问题引发的用户程序错误,如引用空指针的成员内容 NullPointerException,访问数组越界下标的内容 ArrayIndexOutOfBoundsException 这类异常一般是可恢复的,不至于让程序陷入 宕机

java.lang.Error 要比 java.lang.RuntimeException 严重,它意味着程序发生了不可恢复的致命错误:比如栈帧溢出,内存溢出等错误。

受检异常,一般用于受外部因素影响而存在失败风险的操作,比如加载外部类,读取外部文件等。Java 强制要求捕获这类异常并及时处理,否则就必须要在方法签名中通过 throws 关键字表示向上抛出。

Scala 沿用了 Java try-catch 的异常捕获体系,下面是一个简单的示例:

try {
  val a: Int = 10 / 0
  println(a)
} catch {
  case ex: ArithmeticException => ex.printStackTrace()
  case ex: Exception => ex.printStackTrace()
} finally {
  println("释放资源")
}

catch 块内是对 异常类型的模式匹配,从习惯上,小范围的异常写在大范围异常的前面。

Scala 中只有运行时异常。说的再通俗点,我们不会在函数签名中通过 throws 标注任何可能抛出的受检异常。在后期基于函数的纯代数设计中,我们会理解这种侵入性的更改函数签名做法对于函数模块化组合没有任何好处。见:Scala:函数式编程下的异常处理 - 掘金 (juejin.cn)

Scala 可以在 def 函数上面加上一个 @throws注解的方式来 标记 可能抛出的异常:

@throws(classOf[NumberFormatException])
def maybeFailed(stringInt:String): Int= stringInt.toInt

运算符

形式上,我们习惯将定义在 object 单例对象的称之函数 ( function ),将定义在 class 类上的称之为 方法 ( method )。

在命名变量,函数或方法时所使用的字符序列可统称 标识符。可以使用 反引号 区分那些和关键字重名的标识符 ( 但并不推荐 )。

val `val` = 100

Scala 允许使用连续的两个及以上的字符 表示一个标识符,比如 ++--?!^^<-> 等。注意,$_ 以及左右括号,方括号,花括号不能作为标识符。

val ~@! : Int = 0
val <-> : Int = 1
def <== : Int = 2
def =>| : Int = 3

特殊的,这四个单字符 +-*/ 可以用来表示一个标识符。

val + : Int = 0
val - : Int = 1
val / : Int = 2
val * : Int = 3

Scala 丰富的标识符表示法为构建领域内部语言 DSL 提供了基础。为了避免混乱,这些符号标识符一般仅用于某个上下文语境内定义函数或方法,后文统称这样的标识符为 运算符。对 +-*/ 等常见运算符的重定义也称 运算符重载

按照功能来看,运算符可以分为三类:算数运算符赋值运算符关系运算符。按照和操作数的关系,运算符又可分为:

  1. 前缀运算符
  2. 中缀运算符
  3. 后缀运算符

其它语言的大部分基础运算符在 Scala 也是通用的,比如移位 >> ,取模 %,等等,不过需要补充的是,Scala 没有三目运算符 ( 被选择分支替代 ),也没有自增 ++ 自减 -- 运算,因为这两种自操作潜在地破坏了变量原有的状态,这种做法是不被 Scala 推崇的。若要对 var 变量实现自增 / 自减操作,则必须使用 +=-= 表示运算并重赋值。

var i = 3
i += 1

本章的重点是使用 Scala 自定义运算符。

中缀运算符

如果类方法的参数只有一个,那么该方法就可以作为中缀运算符。如:

class State{
    def andThen(other: State) = this
    def inFinal(other: State) = ()
}

val s1 = new State()
val s2 = new State()
val s3 = new State()

s1.andThen(s2).inFinal(s3)
// 中缀表达式写法
s1 andThen s2
// 保留从左到右的运算顺序
s1 andThen s2 inFinal s3

看,我们无意间就生成了一个易于理解的,连贯的最简单 DSL。注意,无论是哪种表现形式,Scala 总是按照从左到右的顺序严格计算。如果追求完全符号化的表达,也可以这样设计:

class State{
  def andThen(other: State) = this
  def inFinal(other: State) = ()
  /** warped andThen*/
  def |=> (other : State) = andThen(this)
  /** warped inFinal */
  def =| (other : State) = inFinal(this)
}

val s1 = new State()
val s2 = new State()
val s3 = new State()

s1 |=> s2 =| s3

后缀运算符

如果类方法是空参数的,那么该方法就可以视作一个后缀运算符。

// Scala 建议通过引入它来开启后缀运算符特性。
import scala.language.postfixOps
class Generator(var zeroV : Int){def next : Int = zeroV + 1}
val generator =new Generator(0)
val v1 = generator next   // return 1
val v2 = generator next   // return 2

注意,这里的 next 方法其实是一个无参数方法,因为它没有小括号 () 表示的参数列表。空参数函数和无参数函数两者存在略微区别,我们之后再说。同理,可以为它设计一些符号化的表达:

class Generator(var zeroV : Int){
  def next : Int = zeroV + 1
  /** warped next */
  def >> : Int = next
}

val generator = new Generator(0)
val v1 = generator >> 

前缀运算符

和中缀运算符,后缀运算不同的是,Scala 只预留了四个前缀运算符 !+, -, ~。前缀运算符用于定义函数,参数列表只绑定一个操作数。用于前缀运算符的方法名称必须附带 unary_ 前缀,它是一个无参数方法。

class WarpedInt(var v: Int){def unary_! : Int = -v}

! new WarpedInt(20)  // return -20

使用前缀运算符的语句行必须要和其它语句行隔开,否则会被认为是跨行的中缀运算符运算而报错。如:

class WarpedInt(var v: Int){def unary_! : Int = -v}
! new WarpedInt(20)  // error: class WrapedInt(var v : Int){/**/} ! new WarpedInt(20)

解决办法之一是上下两行间使用显式的 ; 相隔。

向右运算符

Scala 规定一切以 : 结尾的操作符是向右运算符,如 ::/:+: 等,这都是在 Scala 不可变列表类型 List[T] 中实际定义的运算符。向右运算符是特殊的中缀运算符,方法的持有者位于操作符的右侧,而不是左侧。因此,这种运算符的计算次序是从右到左

当向右运算符被作为普通方法调用时,运算顺序仍然是从左到右。

class ToLeft(var seq : List[Int]){def <-:(i : Int) : ToLeft = new ToLeft(seq.appended(i))}
val nil = new ToLeft(Nil)

val sequenceList = (((nil.<-:(1)).<-:(2)).<-:(3)).<-:(4)
val reversedList = 1 <-: 2 <-: 3 <-: 4 <-: nil
println(reversedList.seq)  // List(4,3,2,1)
println(sequenceList.seq)  // List(1,2,3,4)