印度朋友手把手教你学Scala(9):函数式编程简介


/**
 * 谨献给我最爱的YoYo
 * 原文出处:https://madusudanan.com/blog/scala-tutorials-part-9-intro-to-functional-programming/
 * @author dogstar.huang <chanzonghuang@gmail.com> 2017-02-21
 */

本翻译已征得Madusudanan.B.N同意,并链接在原文教程前面。

由于它的优雅和性能特点,Scala的函数式编程范式在最近几年变得相当流行。

这是关于Scala系列教程的第九章。点击这里可查看整个系列。

在这系列里的前面8章里,我从来都没谈及过Scala函数式编程这一方面,那是因为如果你是这个范式的新手(这世界大多数是),那么伴随着完全陌生和困难的范式,学习这门语言和环境会有一点难学,那就是为什么我一开始没有谈及他们。既然我们已经覆盖了面向对象编程方面到达一定程度,理解起来就稍微更简单了。

目录

  • 看看在2017年的摩尔定律
  • 什么是编程范式?
  • 理解冯·诺依曼编程风格
  • 冯·诺依曼风格有什么问题
  • 归约(Map reduce)和不可变性
  • Scala里的纯函数
  • 高级抽象和避免一个字一个字的概念化程序

看看在2017年的摩尔定律

摩尔定律是一个关于电路中晶体管数量每两年翻一番的预测。就在最近,很多例如英特尔AMD和很多其他芯片制作商意识到,摩尔定律可以是增加核数而不是关注于单核时钟速度。

下面和图表展示了每核的性能,以及它是年复一年攀升的。
图片来源:维基百科

可以看到,每核的性能停滞在3Ghz左右,没有看到以前的增长。

这是另外一张图表,谈论了核的数量增长与时钟速度增长的关系。

数据来源于:Kunle Olukotun, Lance Hammond, Herb Sutter, Burton Smith, Chris Batten and Krste Asanovic

很明显,摩尔定律现在是依据核的数量增长,而不是单核时钟速度的增长。

这个硬件问题现在变成了一个软件问题,程序/操作系统必须稳定地利用多核。

这和函数式编程又有什么关系呢?函数式范式以尽可能最好的方式给予了程序解决这个问题的能力,以及许多其他的好处。继续看下去,挖掘更多信息。

什么是编程范式?

引用自维基百科

编程范型或编程范式(英语:Programming paradigm),(范即模范之意,范式即模式、方法),是一类典型的编程风格,是指从事软件工程的一类典型的风格(可以对照方法学)。

这与设计模式不同,设计模式是高级抽象并且大部分独立于与语言。

编程范式建立在语言中,就像C不支持OOP里的类/对象,而Java不支持指针。

世界上有几百个编程语言,对于全部这些共同之处的是和语言相关某种的范式。

Java是面向对象的,C是过程式的,尽管你可以在Java里进行过程式编程,但它更关注于面向对象编程。

如果你对不同的范式不熟悉,我强烈推荐你看下在线课程:斯坦福 CS107 - 编程范式,它会给你一个非常棒的流行范式概览。

理解冯·诺依曼编程风格

命令式编程/面向对象编程基于一种叫冯·诺依曼架构的东西,它是一个描绘计算机各个部分的计算机架构,如下所示。
图片来源:维基百科

今天的编程风格与上面的架构强烈相关。

  • 程序变量 -> 计算机存储单元(寄存器)
  • 控制语句 -> 跳转指令
  • 赋值语句 -> 获取/存储指令
  • 表达式 -> 内存引用/算术指令

如果你曾经学习/编写过汇编,那么你会很熟悉上面的指令。在任何情况下,如果汇编/CPU/寄存器对你来说就像希腊和拉丁,那么或者复习一下计算机架构将有会很大的帮助。这些课程对架构进行了深度讲解,不管怎样应该能给你关于我们正在讨论的东西一个很好的概念。

用汇编或命令式语言编写的程序称为指令序列,他们表示被计算机逐步执行的指令。

这个概念在很大程度上塑造了C,C++,Java,C#等编程语言。

冯·诺依曼风格有什么问题

那么冯·诺依曼风格有什么问题呢?为什么我们需要一种新的范式?

这位于经典的调查页面:John Backus编程是否可以从冯·诺依曼风格解放出来

对于一个调查页面来说这有点长,但我强烈建议你阅读一下它。很多概念可能现在对你来说没有什么意义,但后面它肯定会变得有意义的。

理解冯·诺依曼风格的编码有什么问题,需要深入的函数式编程知识,以便我们可以对比他们。以接下来的部分,我们会来看几种函数式编程的构造/概念,这些能够帮助我们理解。

归约(Map reduce)和不可变性

当发生了大数据爆炸时,归约曾是它的核心。Hadoop和归约简介是一个开始学习Hadoop和Map Reduce很好的资源。

我们会来看一下非常简单的例子,是关于归约怎么在Apache Spark里工作的。语法并不是非常重要,重点是背后的概念。

以下是一段简单的、可以工作的代码片段,处理了著名的单词计数问题。

val textFile = sc.textFile("hdfs://...")  
val counts = textFile.flatMap(line => line.split(" "))  
                 .map(word => (word, 1))
                 .reduceByKey(_ + _)
counts.saveAsTextFile("hdfs://...")  

归约分为两个阶段,即Map阶段和Reduce阶段。

出于我们的理解,假设以下是要执行单词数的内容,例如要计算每个单词在给定数据集里出现的次数。

This is one of few movies that are truly timeless. And it's entertaining and moving, no matter how many times you view it.

The only other movie I have ever seen that effects me as strongly is To Kill a Mockingbird. Both movies leave me feeling cleaner for having watched them.

It is a simple film, yet it has an everlasting message.  

当你把上面的段落当作一个文本文件给到上面的代码时,它会给出集合中去重后的单词以及每个单词在指定集合里出现了多少次。例如单词movies在集合里出现了2次。

让我们分别来看下单词计数的每行代码。

val textFile = sc.textFile("hdfs://...")  

首先我们读取从来自HDFS的一个文本文件。这个文本文件包含了类似上面示例中给出的内容。

接着语法开始变得奇怪,我特定选择了代码里的Scala部分而不是Java的,因为它能更好地解释这些概念。

flatMpamap是Scala集合类库里的一部分。我不打算详细解释他们,因为他们两个都是很大的一个主题,还不如先关注他们做了什么这个简单的术语上。

通过下面的例子来进行理解,

It is a simple film, yet it has an everlasting message.  

步骤分解如下。

为了简单起见,假设输入的单词在被归约任务处理之前全部转换成了小写。

  • flatMap的输出会是分隔好的单词 -> it,is,a,simple ….
  • 然后map接收这些分隔好的单词并且把他们处理为一个键值对 -> (it,1),(is,1),(a,1) ….
  • 随后reduceByKey做了它名字的事情。它接收例如(it,1),下一个(it,1)这样的单词并且把它们合并成(it,2)。


图片来源:维基百科

整个过程最重要的部分是输入的数据一旦给定就不能再改变,即它是不可变的,如果Spark的worker配置正常的话可以轻易扩展到多核甚至多台机器。

不可变对象是线程安全的认为他们的内容不能被改变。线程安全直接与并行和多核/多机器性能/扩展性有关。如果锁太多,整个操作的速度就会慢下来,而如果没有锁就会导致错误的结果。

为了根除这点,我们需要不可变编程概念以便不需要线程安全。在Scala,不可变性意味着线程安全

在其他语言里它可能是相同的意义,也可能不同。在Scala,样本类是不可变对象/类的典型例子。除了创建的部分,他们还可以在多线程中传递而不会引起任何线程安全问题,因为这些数据是不可变的。

样本类这一章里,我们看到了在创建后改变样本类的值会导致编译时的异常,并且定义他们很容易。

这是为什么在Scala里喜爱不可变性而反对可变编程构造的原因之一。

Scala里的纯函数

函数式编程的核心是纯函数。乍一看,“方法不是和函数一样的吗?”这个问题让人困惑。结果是在Scala他们是不一样的。

注意:我们只是简单地接触函数的概念。在后面的文章里会更多地对它进行解释。

在函数式编程的世界里,纯函数是没有副作用的函数,即它只依赖于输入的参数并且不会修改其他地方的状态。

如果函数做了下面一件或多件事,它就被视为非纯函数。

  • 修改在当前函数/类或者其他类的变量
  • 打印到控制台或者其他地方
  • 存储数据到数据库

这里还有更多好处。转念一想,有人可能会想如果上面这些我都不能做,我还怎么写代码?

在函数式语言里,有用于处理I/O的monad,但monad是一个高级的东西,后面我们会讲到。但请记住,Scala是多范式的,根据手上的问题程序员可以选择不同 风格。

FP的目的不显了移除可变性,而是为了减少它们,使其具有种性能优点。

来看一个例子。

val x = math.sin(10)  

scala.math包的sin函数可以认为是一个纯函数。等一下,它会修改变量,对吗?实际上没有,它返回一个新的值并且没有改变传入的值。

object Runnable extends App {

  val init = 10

  val x = math.sin(init)

  println(x)
  // init没被改变
  println(init)


}

Java程序员可能会认为对于对象,它是按引用传递的,在这样情况下函数是不纯的。它是的,但函数只接收原始类型。在只有传值调用和传名调用(稍候我们会探讨)的Scala里这明显是不同的。

纯函数令人难以置信的有用,它们有以下这些好处。

  • 因为没有可变性调用,所以不需要线程安全。
  • 减少你代码的认知负荷。关于这点,更多请见后面的教程。
  • 既然不依赖任何额外的资源,它们提供了优秀的封装,即使用它的人不需要了解背后发生了什么。

高级抽象和避免一个字一个字的概念化程序

解决复杂性的关键是抽象。这是由John backus发表的文章提出的主要概念,并且不需要多线程和水平扩展。因为摩尔定律以及单核处理器性能看到了天花板,这点最近变得很有名。

可以从归约例子里取一些代码来作为抽象的一个很好的例子。

object Runnable extends App {

  val x = List(10,20,30,40)

  val y = x.map( i => i* 3)

  println(y)

}

这是一个遍历转换,即把列表中的每个元素乘以3并且返回一个新的列表。

传统的循环,有一点乏味。

import scala.collection.mutable.ListBuffer

object Runnable extends App {

  val x = List(10,20,30,40)

  val mutable = new ListBuffer[Int]

  for (e <- x) {
    mutable += (e * 3)
  }

  println(mutable.toList)

}

因为List是一个不可变的数据结构,我们需要引入一个额外的可变的称为ListBuffer的数据结构。这已经打破了我们前面讨论的不可变原则,而且函数式的版本更为清晰明了。

现在运行时可以接收这些代码块,并且与for循环相比,还可以进行大量的优化,这就是高级抽象的强大之处。

总结一下到目前为止我们学到的内容。

  • 我们明白了为什么函数式编程最近变得流行,和摩尔定律的意义。
  • 为什么不可变性对于线程安全和性能是重要的。
  • 纯函数的概念以及其优点。
  • 高级构造与其重要性。

这是进入函数式编程世界非常漫长但有益的旅途的开始。还有很多核心概念要涵盖,但这篇文章关注于为什么这是一件大事情。

直到下一次。^_^

dogstar

一位喜欢翻译的开发人员,艾翻译创始人之一。

广州