Android 开发者如何使用函数式编程 (一)
最近我花了很多时间学习 Elixir — 一门极好的函数式编程语言,很适合初学者学习。
这让我不禁思考,为什么不在Android开发中使用一些函数式编程的思想和技术呢?
大多数人在听到函数式编程这个术语时,都会想到Hacker News上那些关于Monads,高阶函数,抽象数据类型的帖子。它好像一个离普通程序员很遥远的神秘领域,仅仅属于顶尖的黑客们。
管它的!我想告诉你你也可以学它,使用它,也可以用它打造漂亮的app-- 代码优雅,可读性强,错误更少的app。
欢迎来到面向Android开发者的函数式编程(简称FP)。在这个系列文章中,我将带领大家一起学习函数式编程的基础以及如何在老当益壮的Java和编程新贵Kotlin中使用 FP。本文旨在实用性,尽量减少术语的使用。
FP是一个庞大的话题。我们只学习对写Android代码有用的概念和技术。为了内容的完整性我们可能会涉及到一些不能直接使用的概念,但是我们会尽量保持内容的相关性。
准备好了?那开始吧。
什么是函数式编程,为什么我们要使用它?
问得好。函数式编程是一些列编程概念的总称,这个术语本身并不能完全反应其所指的含义。其核心就是,它是一种把程序看作数学方程式的编程风格,并避免可变状态和副作用(马上就会谈到这点)。
FP核心思想强调:
-
声明式代码 —— 程序员应该关心是什么,让编译器和运行环境去关心怎样做。
-
明确性 —— 代码应该尽可能的明显。尤其是要隔离副作用避免意外。要明确定义数据流和错误处理,要避免 GOTO 语句和异常,因为它们会将应用置于意外的状态。
-
并发 —— 因为纯函数的概念,大多数函数式代码默认都是并行的。由于CPU运行速度没有像以前那样逐年加快((详见 摩尔定律)), 普遍看来这个特点导致函数式编程渐受欢迎。以及我们也必须利用多核架构的优点,让代码尽量的可并行。
-
高阶函数 —— 和其他的基本语言元素一样,函数是一等公民。你可以像使用 string 和 int 一样的去传递函数。
-
不变性 —— 变量一经初始化将不能修改。一经创建,永不改变。如果需要改变,需要创建新的。这是明确性和避免副作用之外的另一方面。如果你知道一个变量不能改变,当你使用时会对它的状态更有信心。
声明式、明确性和可并发的代码,难道不是更易推导以及从设计上就避免了意外吗?真希望已经激起了你的兴趣。
作为本系类文章的第一部分,我们从一些 FP 的基本概念开始:纯粹、副作用和排序。
纯函数
如果函数的输出只取决于它的输入并且不存在副作用(紧接着我们就会谈到副作用),那么这个函数就是纯函数。让我们来看一个例子。
考虑下面这个简单的求和函数,一个数字从文件读取,一个数字来自参数。
Java
int add(int x) {
int y = readNumFromFile();
return x + y;
}
Kotlin
fun add(x: Int): Int {
val y: Int = readNumFromFile()
return x + y
}
这个函数的输出不仅仅依赖于输入,还依赖于 readNumFromFile() 的返回,对于相同的参数 x 可能产生不同的输出。因此我就称这个函数不是纯函数。
让我们把它转为纯函数。
Java
int add(int x, int y) {
return x + y;
}
Kotlin
fun add(x: Int, y: Int): Int {
return x + y
}
现在函数的输出只依赖于它的输入了。对于给定的 x 和 y,函数总会返回相同的输出。现在这个函数是纯函数了。数学函数的运作也是这样,一个数学函数的输出只依赖于输入 —— 这也是为什么函数式编程比通常的编程风格更接近于数学的原因。
P.S. 没有输入也是一种输入。如果一个函数没有输入并且每次的返回总是相同不变的,那么它也是一个纯函数。
P.P.S. 固定输入总是返回相同输出的属性也被成为 引用透明性,当讨论纯函数时你可能会遇到这种说法。
副作用
我们同样以刚才的那个求和函数为例。我们将修改这个函数,把结果写到一个文件中。
Java
int add(int x, int y) {
int result = x + y;
writeResultToFile(result);
return result;
}
Kotlin
fun add(x: Int, y: Int): Int {
val result = x + y
writeResultToFile(result)
return result
}
该函数将计算结果写到了一个文件中,也就是修改了外界的状态。那么该函数就是有 副作用的,不再是纯函数了。
任何修改外界状态(修改变量、写文件、存储 DB、删除内容等)的代码都是有副作用的。
FP 中应该避免使用有副作用的函数,因为它们不再是纯函数而是依赖于历史上下文。代码的上下文不是由自身决定,这将导致它们更难推导。
我们假设你写了一段依赖缓存的代码,代码的输出依赖于是否有人已经对缓存做了写操作、写入了什么、什么时候写入的、写入的数据是否有效等。你无法知道你的程序在做什么,除非你知道它依赖的缓存的所有可能状态。如果你拓展代码以包括所有应用依赖的内容 —— 网络、数据库、文件、用户输入等等,那么会变得很难确切的知道正在发生什么,以及很难一次性将所有内容都考虑到。
这是否意味着我们不使用网络、数据库和缓存了?当然不是。当执行结束之后,应用往往需要做些什么。以 Android 应用为例,往往是更新 UI 以便用户从我们的应用中真正地获得有用的内容。
FP 最伟大的概念并非完全的放弃副作用,而是包容、隔离它们。我们将副作用置于系统的边缘,尽可能减少影响,使得应用更易懂,避免有副作用的函数将应用弄得一团糟。在本系列后面的文章中,研究应用的函数式架构时,我们会具体的讨论这个问题。
排序
如果我们有几个没有副作用的纯函数,那么它们的执行顺序是无关紧要的。
我们看个例子,我们有一个函数,函数会调用 3 个纯函数
Java
void doThings() {
doThing1();
doThing2();
doThing3();
}
Kotlin
fun doThings() {
doThing1()
doThing2()
doThing3()
}
我们明确的知道这些函数互不依赖(因为一个函数的输出不是另一个的输入)并且我们知道它们不会改变系统的任何内容(因为它们是纯函数)。这样它们的执行顺序是完全可交换的。
独立的纯函数的执行顺序是可重排序和优化的。需要注意的是,如果 doThing1() 的结果是 doThing2() 的输入,那么它们需要按顺序执行,但是 doThing3() 依然可以重排序在 doThing1() 之前执行。
可重排序的特性对我们来说有什么益处?当然是并发了。我们可以在 3 个 CPU 上分别运行它们,而不需要担心发生任何问题。
多数情况下,像 Haskell 这样高级纯函数式语言的编译器中,可以通过分析你的代码判断是否可并行,可以防止你出现搬起石头砸自己的脚的事情(比如死锁、条件竞争等)。这些编译器理论上可以自动并行化你的代码(虽然据我所知目前编译器都不支持,但是相关的研究正在进行)。
尽管你的编译器并不像上面说的那样,但单作为一个程序员,有能够根据函数的签名判断代码是否可并行,并且避免代码存在隐性副作用而导致线程问题的能力还是很重要的。
总结
希望第一本分已经激起了你对 FP 的兴趣。纯粹性、无副作用的函数是的代码更易读并且是实现并行的第一步。
在我们开始实现并行之前,我们需要了解下 不变性。在本系列文章的第二部分将进行探讨,并且可以看到在不需要借助锁和互斥变量的情况下,纯函数和不变性是如何帮助我们编写简单易懂的可并行代码的。