2.2 优先采用面向表达式编程
深入理解Scala
面向表达式编程是个术语,意思是在代码中使用表达式而不用语句。表达式和语句的区别是什么?语句是可以执行的东西,表达式是可以求值的东西。在实践中这有什么意义呢?表达式返回值,语句执行代码,但是不返回值。本节我们将学习面向表达式编程的全部知识,并理解它对简化程序有什么帮助。我们也会看一下对象的可变性,以及可变性与面向表达式编程的关系。
作者注:语句VS表达式
语句是可以执行的东西,表达式是可以求值的东西。
表达式是运算结果为一个值的代码块。Scala的一些控制块也是表达式。这意味着如果这个控制结构是有分支的,那么每个分支也必须被计算为一个值。if语句就是个极佳的例子。if语句检查条件表达式,然后根据条件表达式的值返回其中一个分支的结果。我们来看个简单的REPL会话:
如你所见,Scala的if块是个表达式。我们的第一个if块返回5,也就是表达式true的结果。第二个if块返回hello,也就是表达式false的结果。要在Java里达到类似的目的,你得用下文所示的?:语法:
因此Java里的if块和?:表达式的区别在于if不被运算为一个值,Java里你不能把if块的结果赋值给一个变量。而Scala统一了?:和if块的概念,所以Scala里没有?:语法,你只需要用if块就够了。这只是面向表达式编程的开始,实际上,Scala绝大部分语句都返回其最后一个表达式的值作为结果。
2.2.1 方法和模式匹配
面向表达式编程挑战了其他语言的某些好的实践。用Java编程时,有个常用的实践是每个方法只有一个返回点。这意味着如果方法里有某种条件逻辑,开发者会创建一个变量存放最终的返回值。方法执行的时候,这个变量会被更新为方法要返回的值。每个方法的最后一行都会是个return语句。我们来看个例子。
如你所见,result变量用来存放最终结果。代码流过一个模式匹配,相应地设置出错字符串,然后返回结果变量。我们可以用模式匹配提供的面向表达式语法稍微改进一下代码。事实上,模式匹配上返回一个值,类型为所有case语句返回的值的公共超类。如果一个模式都没有匹配上,模式匹配会抛出异常,确保我们要么得到返回值要么出错。我们把上面的代码翻译成面向表达式的模式匹配实现。
你应该注意到两件事。首先,我们把result变量改成了val,让类型推导来判断类型。因为我们不在需要在赋值后改变result的值,模式匹配应该能够判断唯一的值(和类型)。所以我们不仅减少了代码的大小和复杂度,我们还增加了程序的不变性。不变性(immutability)是指对象或变量赋值后就不再改变状态,可变性(mutability)是指对象或变量在其生命周期中能够被改变或操纵。我们将在下一节探讨可变性和面向表达式编程。你经常会发现面向表达式编程和不变对象合作无间。
我们做的第二件事是去掉了case语句里的所有赋值。case语句的最后一个表达式就是case语句的“结果”。我们可以在每个case语句里嵌套更深的逻辑,只要在最后能得到某种形式的表达式结果就行。如果我们不小心忘了返回结果,或者返回结果不对,编译器也会警告我们。
代码看上去已经简洁多了,不过我们还可以再改进一点。用Scala开发时,大部分开发者会避免在代码里使用return语句,而更喜欢用最后一句表达式作为返回值(这也是所有其他面向表达式语言的风格)。实际上,对于createErrorMessage方法,我们可以完全去掉result这个中间变量。我们看下最后改进的结果。
你注意到我们甚至没为这个方法开个代码块吗?模式匹配是这个方法唯一一个语句,而它返回个字符串类型的表达式。我们完全把这个方法转化为了面向表达式的语法。注意到现在代码变得简洁得多,表达力也强多了吗?同时请注意,如果有任何类型不匹配或者无法走到的(unreachable)case语句,编译器会警告我们。
2.2.2 可变性
面向表达式编程一般与不变性编程(immutable programming)搭档得很好,但是与可变对象协作就没那么好了。不变性是个术语,拿对象来说,一旦对象构造完毕,其状态就不再改变。面向表达式编程和可变性(也就是对象在其生命周期中可以改变状态)混搭的时候,事情就变得复杂了一点。因为使用可变对象的代码一般倾向于用命令式(imperative)的风格编码。
命令式编码可能是你以前熟悉的风格。很多早期语言,如C、Fortran和Pascal都是命令式的。命令式代码一般由语句构成,而不是表达式。先创建对象,设定状态,然后执行语句,而语句会“操纵”或改变对象的状态。对那些没有对象的语言也是一样,只不过改成了操纵变量和结构。我们来看个命令式编码的例子。
注意看这里构造了一个Vector,然后通过magnify方法操纵其状态。而面向表达式的代码喜欢让所有的语句返回某个表达式或值,magnify方法也不例外。在这个操纵对象的例子里,应该返回什么值呢?一个选择是返回刚被操纵过状态的对象。
乍看上去这是个很棒的选择,但实际上有严重的缺陷。尤其难以判断对象的状态是什么时候被改变的,在跟不变对象混用时缺陷就更明显。假设Vector2D的 - 方法符合数学上的定义,请你试试看能否判断出下面这段代码在结束时会打印出什么值?
最后一句表达式的结果是什么呢?第一眼看上去结果应该是vector(3.0,3.0)减去vector(6.0,0.0),也就是(-3.0,3.0)。然而这里面每个变量都是可变的,也就是说变量的值是按照操作顺序修改的。我们来演算一下实际编译的结果。首先x,vector(1.0,1.0)被放大3倍变成了(3.0,3.0)。然后我们用x减y,x变成了(2.0,4.0)。为什么?因为 -方法右边的代码要先计算,其中(x-y)要先计算。接着我们再把x放大3倍,变成了(6.0,12.0)。最后我们用x减去x自己,结果是(0.0,0.0),你没看错,x自己减自己。为什么?因为减号左边的表达式和减号右边的表达式都是x变量开头的。因为我们使用可变对象,也就说每个表达式最后返回x自身。所以不管我们做什么,我们最后都是调用x-x,结果就是vector(0.0,0.0)。
因为存在这种混淆性,在用面向表达式编程时最好使用不可变对象。尤其在有操作符重载的场合下,比如上例。而在有些场景下可变性和面向表达式编程也可以合作得很好,尤其是在使用模式匹配或if语句时。
编码时一个常见的任务是根据某个值查找某个对象的值。这些对象可以是可变的,也可以是不变的。而面向表达式编程可以发挥作用的地方是简化查找。我们来看个简单的例子:根据用户点击的菜单按钮查找需要执行的操作。当按下菜单按钮的时候,我们从事件系统接受到一个事件。这个事件里有哪个按钮被按下的标记。我们要执行某种操作并返回状态。我们看下面的代码。
注意看我们是怎么就地操纵对象并返回结果的。我们没有明确地用return语句,而是简单地写下我们打算返回的表达式。你可以看到这样的代码比创建一个用于存放返回值的变量来得简洁。也可以看到在表达式里混入操纵状态的语句导致代码的清晰性有所降低。这是我们更推崇不变性代码的原因之一,也就是下一节的主题。
面向表达式编程可以减少样板代码(boiler plate),使代码更优雅。其做法是让所有语句返回有意义的值,这样就可以减少代码的凌乱,增加代码的表达力了。现在是时候学习为什么我们要关注不变性了。