Android之FP函数式编程

原文地址:Functional Programming for Android developers — Part 1

最近我花了很多时间学习 Elixir — 一门极好的函数式编程语言,很适合初学者学习。

这让我不禁思考,为什么不在Android开发中使用一些函数式编程的思想和技术呢?

大多数人在听到函数式编程这个术语时,都会想到Hacker News上那些关于Monads,高阶函数,抽象数据类型的帖子。它好像一个离普通程序员很遥远的神秘领域,仅仅属于顶尖的黑客们。

管它的!我想告诉你你也可以学它,使用它,也可以用它打造漂亮的app-- 代码优雅,可读性强,错误更少的app。

欢迎来到面向Android开发者的函数式编程(简称FP)。在这个系列文章中,我将带领大家一起学习函数式编程的基础以及如何在老当益壮的Java和编程新贵Kotlin中使用 FP。本文旨在实用性,尽量减少术语的使用。

FP是一个庞大的话题。我们只学习对写Android代码有用的概念和技术。为了内容的完整性我们可能会涉及到一些不能直接使用的概念,但是我们会尽量保持内容的相关性。

准备好了?那开始吧。

什么是函数式编程,为什么我们要使用它?

问得好。函数式编程是一些列编程概念的总称,这个术语本身并不能完全反应其所指的含义。其核心就是,它是一种把程序看作数学方程式的编程风格,并避免可变状态和副作用(马上就会谈到这点)。

FP核心思想强调:

  • 声明式代码 —— 程序员应该关心是什么,让编译器和运行环境去关心怎样做。

  • 明确性 —— 代码应该尽可能的明显。尤其是要隔离副作用避免意外。要明确定义数据流和错误处理,要避免 GOTO 语句和异常,因为它们会将应用置于意外的状态。

  • 并发 —— 因为纯函数的概念,大多数函数式代码默认都是并行的。由于CPU运行速度没有像以前那样逐年加快((详见 摩尔定律)), 普遍看来这个特点导致函数式编程渐受欢迎。以及我们也必须利用多核架构的优点,让代码尽量的可并行。

  • 高阶函数 —— 和其他的基本语言元素一样,函数是一等公民。你可以像使用 string 和 int 一样的去传递函数。

  • 不变性 —— 变量一经初始化将不能修改。一经创建,永不改变。如果需要改变,需要创建新的。这是明确性和避免副作用之外的另一方面。如果你知道一个变量不能改变,当你使用时会对它的状态更有信心。

声明式、明确性和可并发的代码,难道不是更易推导以及从设计上就避免了意外吗?真希望已经激起了你的兴趣。

作为本系类文章的第一部分,我们从一些 FP 的基本概念开始:纯粹副作用排序

纯函数

如果函数的输出只取决于它的输入并且不存在副作用(紧接着我们就会谈到副作用),那么这个函数就是纯函数。让我们来看一个例子。

考虑下面这个简单的求和函数,一个数字从文件读取,一个数字来自参数。

Java

 
  1. int add(int x) {
  2.     int y = readNumFromFile();
  3.     return x + y;
  4. }

Kotlin

 
  1. fun add(x: Int): Int {
  2.     val y: Int = readNumFromFile()
  3.     return x + y
  4. }

这个函数的输出不仅仅依赖于输入,还依赖于 readNumFromFile() 的返回,对于相同的参数 x 可能产生不同的输出。因此我就称这个函数不是纯函数。

让我们把它转为纯函数。

Java

 
  1. int add(int x, int y) {
  2.     return x + y;
  3. }

Kotlin

 
  1. fun add(x: Int, y: Int): Int {
  2.     return x + y
  3. }

现在函数的输出只依赖于它的输入了。对于给定的 x 和 y,函数总会返回相同的输出。现在这个函数是纯函数了。数学函数的运作也是这样,一个数学函数的输出只依赖于输入 —— 这也是为什么函数式编程比通常的编程风格更接近于数学的原因。 

P.S. 没有输入也是一种输入。如果一个函数没有输入并且每次的返回总是相同不变的,那么它也是一个纯函数。

P.P.S. 固定输入总是返回相同输出的属性也被成为 引用透明性,当讨论纯函数时你可能会遇到这种说法。

副作用

我们同样以刚才的那个求和函数为例。我们将修改这个函数,把结果写到一个文件中。

Java

 
  1. int add(int x, int y) {
  2.     int result = x + y;
  3.     writeResultToFile(result);
  4.     return result;
  5. }

Kotlin

 
  1. fun add(x: Int, y: Int): Int {
  2.     val result = x + y
  3.     writeResultToFile(result)
  4.     return result
  5. }

该函数将计算结果写到了一个文件中,也就是修改了外界的状态。那么该函数就是有 副作用的,不再是纯函数了。

任何修改外界状态(修改变量、写文件、存储 DB、删除内容等)的代码都是有副作用的。

FP 中应该避免使用有副作用的函数,因为它们不再是纯函数而是依赖于历史上下文。代码的上下文不是由自身决定,这将导致它们更难推导。

我们假设你写了一段依赖缓存的代码,代码的输出依赖于是否有人已经对缓存做了写操作、写入了什么、什么时候写入的、写入的数据是否有效等。你无法知道你的程序在做什么,除非你知道它依赖的缓存的所有可能状态。如果你拓展代码以包括所有应用依赖的内容 —— 网络、数据库、文件、用户输入等等,那么会变得很难确切的知道正在发生什么,以及很难一次性将所有内容都考虑到。

这是否意味着我们不使用网络、数据库和缓存了?当然不是。当执行结束之后,应用往往需要做些什么。以 Android 应用为例,往往是更新 UI 以便用户从我们的应用中真正地获得有用的内容。

FP 最伟大的概念并非完全的放弃副作用,而是包容、隔离它们。我们将副作用置于系统的边缘,尽可能减少影响,使得应用更易懂,避免有副作用的函数将应用弄得一团糟。在本系列后面的文章中,研究应用的函数式架构时,我们会具体的讨论这个问题。

排序

如果我们有几个没有副作用的纯函数,那么它们的执行顺序是无关紧要的。

我们看个例子,我们有一个函数,函数会调用 3 个纯函数

Java

 
  1. void doThings() {
  2.     doThing1();
  3.     doThing2();
  4.     doThing3();
  5. }

Kotlin

 
  1. fun doThings() {
  2.     doThing1()
  3.     doThing2()
  4.     doThing3()
  5. }

我们明确的知道这些函数互不依赖(因为一个函数的输出不是另一个的输入)并且我们知道它们不会改变系统的任何内容(因为它们是纯函数)。这样它们的执行顺序是完全可交换的。

独立的纯函数的执行顺序是可重排序和优化的。需要注意的是,如果 doThing1() 的结果是 doThing2() 的输入,那么它们需要按顺序执行,但是 doThing3() 依然可以重排序在 doThing1() 之前执行。

可重排序的特性对我们来说有什么益处?当然是并发了。我们可以在 3 个 CPU 上分别运行它们,而不需要担心发生任何问题。

多数情况下,像 Haskell 这样高级纯函数式语言的编译器中,可以通过分析你的代码判断是否可并行,可以防止你出现搬起石头砸自己的脚的事情(比如死锁、条件竞争等)。这些编译器理论上可以自动并行化你的代码(虽然据我所知目前编译器都不支持,但是相关的研究正在进行)。

尽管你的编译器并不像上面说的那样,但单作为一个程序员,有能够根据函数的签名判断代码是否可并行,并且避免代码存在隐性副作用而导致线程问题的能力还是很重要的。

总结

希望第一本分已经激起了你对 FP 的兴趣。纯粹性、无副作用的函数是的代码更易读并且是实现并行的第一步。

在我们开始实现并行之前,我们需要了解下 不变性。在本系列文章的第二部分将进行探讨,并且可以看到在不需要借助锁和互斥变量的情况下,纯函数和不变性是如何帮助我们编写简单易懂的可并行代码的。

------------------------------------------分割线------------------------------------------

原文链接:https://github.com/xitu/gold-miner/blob/master/TODO/functional-programming-for-android-developers-part-2.md

原文地址:Functional Programming for Android developers?—?Part 2

上面我们学习了纯粹性*、副作用排序**。在本部分中,我们将讨论不变性并发

不变性

不变性是指一旦一个值被创建,它就不可以被修改。

假设我有一个像这样的 Car 类:

 
  1. public final class Car {
  2.     private String name;
  3.  
  4.     public Car(final String name) {
  5.         this.name = name;
  6.     }
  7.  
  8.     public void setName(final String name) {
  9.         this.name = name;
  10.     }
  11.  
  12.     public String getName() {
  13.         return name;
  14.     }
  15. }

因为它有一个 setter,我可以在创建之后修改车的名称:

 
  1. Car car = new Car("BMW");
  2. car.setName("Audi");

这个类不是不可变的。他在创建之后可以被改变。

我们把它变成不可变的。要做到这一点,我们必须:

  • 把 name 变量设为 final

  • 移除 setter。

  • 把这个类也设为 final,这样另一个类就不可以继承它并修改它的内容。

 
  1. public final class Car {
  2.     private final String name;
  3.  
  4.     public Car(final String name) {
  5.         this.name = name;
  6.     }
  7.  
  8.     public String getName() {
  9.         return name;
  10.     }
  11. }

如果现在有人需要创建一个新的 car,他们需要初始化一个新的对象。没有人可以在 car 被创建之后修改它。这个类现在是不可变的了。

但是 getName() 方法呢?它在把名称返回给外部世界对吧?如果有人在通过 getter 取得引用之后修改了 name 的值怎么办?

在 Java 中,string在默认情况下是不可变的。哪怕有人获得了对 name string 的引用并修改它,他们也只能得到 name string 的拷贝,原先的 string 保持不变。

但是可变的东西怎么办?比如一个 list?我们修改一下 Car 类,使它具有一个驾驶员的 list。

 
  1. public final class Car {
  2.     private final List<String> listOfDrivers;
  3.  
  4.     public Car(final List<String> listOfDrivers) {
  5.         this.listOfDrivers = listOfDrivers;
  6.     }
  7.  
  8.     public List<String> getListOfDrivers() {
  9.         return listOfDrivers;
  10.     }
  11. }

在这种情况下,有人可以通过 getListOfDrivers() 方法取得我们内部 list 的一个引用,并修改这个 list。这样,我们的类就是可变的了。

要让它不可变,我们必须在 getter 中返回一个 list 的深度拷贝。这样,新的 list 就可以被调用者安全地修改。深度拷贝的含义是我们递归地复制所有依赖它的数据。例如,如果这是一个 Driver 类的 list而不是简单的 string 列表,我们就必须复制每一个 Driver 对象。否则,我们就会创建一个新的 list,其内容是对原先 Driver 对象的引用,而这些对象是可变的。在我们的类中,由于这个                                    list 是由不可变的 string 组成的,我们可以这样创建一个深度拷贝:

 
  1. public final class Car {
  2.     private final List<String> listOfDrivers;
  3.  
  4.     public Car(final List<String> listOfDrivers) {
  5.         this.listOfDrivers = listOfDrivers;
  6.     }
  7.  
  8.     public List<String> getListOfDrivers() {
  9.         List<String> newList = new ArrayList<>();
  10.         for (String driver : listOfDrivers) {
  11.             newList.add(driver);
  12.         }
  13.         return newList;
  14.     }
  15. }

现在这个类就是真正不可变的了。

并发

好了,不可变是很酷,但为什么要用它?我们在第一部分中已经讨论过,纯函数让我们很容易地实现并发。而且,如果一个对象是不可变的,它就很容易在纯函数中使用,因为你不能通过改变它而造成副作用。

来看一个例子。假设我们在 Car 中添加一个 getNoOfDrivers 方法,并允许外部调用者修改 driver 的数量,从而使它可变:

 
  1. public class Car {
  2.     private int noOfDrivers;
  3.  
  4.     public Car(final int noOfDrivers) {
  5.         this.noOfDrivers = noOfDrivers;
  6.     }
  7.  
  8.     public int getNoOfDrivers() {
  9.         return noOfDrivers;
  10.     }
  11.  
  12.     public void setNoOfDrivers(final int noOfDrivers) {
  13.         this.noOfDrivers = noOfDrivers;
  14.     }
  15. }

假设有两个线程共享 Car 类的实例:Thread_1Thread_2Thread_1 需要基于 driver 的数量做一些计算,所以它调用了 getNoOfDrivers()。同时 Thread_2 开始执行,并修改了 noOfDrivers 变量。Thread_1 并不知道这个改变,愉快地继续它的计算。这些计算是不对的,因为                                    Thread_2 已经修改了变量的状态,而 Thread_1 并不知道。

下面的流程图说明了这个问题:

这是一个名为“读-修改-写问题”的典型资源竞争。传统的解决方案是使用锁和互斥。这样,同时只有一个线程可以操纵共享数据,在操作结束之后才释放锁(在我们的例子中,Thread_1 将持有对 Car 的锁,直到它完成计算)。

这种基于锁的资源管理是很难以保证安全的。它会造成极其难以分析的并发 bug。许多程序员在面对死锁和活锁时会失去理智。

不可变性如何解决这个问题呢?我们再次把 Car 设为不可变:

 
  1. public final class Car {
  2.     private final int noOfDrivers;
  3.  
  4.     public Car(final int noOfDrivers) {
  5.         this.noOfDrivers = noOfDrivers;
  6.     }
  7.  
  8.     public int getNoOfDrivers() {
  9.         return noOfDrivers;
  10.     }
  11. }

现在,Thread_1 可以放心地计算,因为 Thread_2 保证无法修改这个对象。如果 Thread_2 想要修改 Car,那么它将会创建它自己的拷贝,而 Thread_1 完全不会受到影响。不需要任何锁。

不可变性保证共享数据在默认状况下就是线程安全的。不应该被修改的东西是不能被修改的。

如果我们需要全局可变状态怎么办?

要写出有用的应用,我们在很多情况下需要共享可变的状态。我们可能会真正需要更新 noOfDrivers ,并把改变反映到整个系统中去。我们在下一章讨论函数式架构时,将使用状态隔离处理这种情况,并把副作用推到系统的边缘。

持久数据结构

不可变对象可能很好,但如果我们不加限制地使用它们,它们将会给垃圾回收器造成负担,从而导致性能问题。函数式编程向我们提供具有不可变性,并能最小化对象创建的数据结构。这些专门化的数据结构被称为持久数据结构

持久数据结构在被修改时,总会保留自己之前的版本。这些数据结构实际上是不可变的。对它们的操作不会(可见地)更新数据结构,而是返回一个新的修改过的结构。

假设我们需要把这些 string 存储在内存中:reborn, rebate, realize, realizes, relief, red, redder

我们可以分开储存它们,但这需要的内存超出必要的限度。如果仔细看的话,我们可以看到这些 string 有很多共同的字符,我们可以用一个 trie 树储存它们(并不是所有的 trie 树都是持久的,但它是我们用来实现持久数据结构的工具之一):

这是持久数据结构的基本工作原理。如果一个新的 string 被加入,我们就创建一个新的节点,并把它链接到正确的位置。如果一个使用这个结构的对象需要删除一个节点,我们只要停止引用它即可。然而,实际的节点不会被从内存中删除,这样副作用就可以被避免。这保证引用这个数据结构的其它对象可以继续使用它。如果没有其它对象引用它,我们可以回收整个结构以收回内存。

在 Java 中使用持久数据结构并不是一个激进的想法。Clojure 是一个函数式语言,它在 JVM 上运行,并有一整个标准库的持久数据结构。你可以在 Android 代码中直接使用 Clojure 的标准库,但它很大而且有很多方法。我找到了一个更好的替代方法:一个叫做 PCollections                                    的库。它有 427 个方法和 48Kb dex 文件大小 ,很适合我们的需要。

作为一个例子,这是我们使用 PCollections 创建并使用一个持久链表时的情形:

 
  1. ConsPStack<String> list = ConsPStack.*empty*();
  2. System.*out*.println(list);  // []
  3.  
  4. ConsPStack<String> list2 = list.plus("hello");
  5. System.*out*.println(list);  // []
  6. System.*out*.println(list2); // [hello]
  7.  
  8. ConsPStack<String> list3 = list2.plus("hi");
  9. System.*out*.println(list);  // []
  10. System.*out*.println(list2); // [hello]
  11. System.*out*.println(list3); // [hi, hello]
  12.  
  13. ConsPStack<String> list4 = list3.minus("hello");
  14. System.*out*.println(list);  // []
  15. System.*out*.println(list2); // [hello]
  16. System.*out*.println(list3); // [hi, hello]
  17. System.*out*.println(list4); // [hi]

可见,没有任何一个 list 是在原位被修改的。每次进行一个修改时,它都会返回一个新的拷贝。

PCollections 有一些标准持久数据结构。它们是针对多种不同的用例实现的,都很值得探索。他们都很适合与易用的 Java 的标准集合库一起使用。

持久数据结构的范围是很广泛的,而这一部分只是触及了冰山的一角。如果你对学习更多相关知识感兴趣,我强烈推荐 Chris Okasaki 的纯函数数据结构

总结

不可变性纯粹性是帮助我们写出安全的并发代码的强力组合。现在我们已经学习了足够多的概念,我们可以在下一部分中看一看如何为 Android 应用设计函数式框架。

额外内容

我在 Droidcon India 中做了一个关于不可变性和并发的报告。希望你们喜欢。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值