函数式编程杂谈
更新:HHH   时间:2023-1-7


本文首发于 vivo互联网技术 微信公众号 
链接:https://mp.weixin.qq.com/s/gqw57pBYB4VRGKmNlkAODg
作者:张文博

比起命令式编程,函数式编程更加强调程序执行的结果而非执行的过程,倡导利用若干简单的执行单元让计算结果不断演进,逐层推导出复杂的运算。本文通过函数式编程的一些趣味用法来阐述学习函数式编程的奇妙之处。

一、编程范式综述

编程是为了解决问题,而解决问题可以有多种视角和思路,其中普适且行之有效的模式被归结为“编程范式”。编程语言日新月异,从汇编、Pascal、C、C++、Ruby、Python、JS,etc...其背后的编程范式其实并没有发生太多变化。抛开各语言繁纷复杂的表象去探究其背后抽象的编程范式可以帮助我们更好地使用computer进行compute。

1.命令式

计算机本质上是执行一个个指令,因此编程人员只需要一步步写下需要执行的指令,比如:先算什么再算什么,怎么输入怎么计算怎么输出。所以编程语言大多都具备这四种类型的语句:

  1. 运算语句将结果存入存储器中以便日后使用;

  2. 循环语句使得一些语句可以被反复运行;

  3. 条件分支语句允许仅当某些条件成立时才运行某个指令集合;

  4. 以及存有争议的类似goto这样的无条件分支语句。

使得执行顺序能够转移到其他指令之处。

无论使用汇编、C、Java、JS 都可以写出这样的指令集合,其主要思想是关注计算机执行的步骤,即一步一步告诉计算机先做什么再做什么。所以命令式语言特别适合解决线性的计算场景,它强调自上而下的设计方式。这种方式非常类似我们的工作、生活,因为我们的日常活动都是按部就班的顺序进行的,甚至你可以认为是面向过程的。也比较贴合我们的思维方式,因此我们写出的绝大多数代码都是这样的。

2.声明式

声明式编程是以数据结构的形式来表达程序执行的逻辑,它的主要思想是告诉计算机应该做什么,但不指定具体要怎么做(当然在一些场景中,我们也还是要指定、探究其如何做)。SQL 语句就是最明显的一种声明式编程的例子,例如:“SELECT * FROM student WHERE age> 18”。因为我们归纳剥离了how,我们就可以专注于what,让数据库来帮我们执行、优化how。

有时候对于某个业务逻辑目前没有任何可以归纳提取的通用实现,我们只能写命令式编程代码。当我们写成以后,如果进行思考归纳抽象、进一步优化,就为以后的声明式做下铺垫。

通过对比,命令式编程模拟电脑运算,是行动导向的,关键在于定义解法,即“怎么做”,因而算法是显性而目标是隐性的;声明式编程模拟人脑思维,是目标驱动的,关键在于描述问题,即“做什么”,因而目标是显性而算法是隐性的。

3.函数式

函数式编程将计算机运算视为函数运算,并且避免使用程序状态以及易变对象。这里的“函数”不是指计算机中的函数,而是指数学中的函数,即自变量的映射。也就是说一个函数的值仅决定于函数参数的值,不依赖其他状态。比如f(x),只要x不变,不论什么时候调用,调用几次,值都是不变的。比起命令式编程,函数式编程更加强调程序执行的结果而非执行的过程,倡导利用若干简单的执行单元让计算结果不断演进,逐层推导出复杂的运算,而不是设计一个复杂的执行过程。函数作为一等公民,可以出现在任何地方,比如你可以把函数作为参数传递给另一个函数、还可以将函数作为返回值。

函数式编程的特点:

  1. 减少了可变量的声明,程序更为安全;

  2. 相比命令式编程,少了非常多的状态变量的声明与维护,天然适合高并发多线程并行计算等任务,我想这也是函数是编程近年又大热的重要原因之一;

  3. 代码更为简洁,但是可读性是高是低也依赖于不同场景、仁者见仁智者见智。

二、函数式编程的一些趣味用法

1.Closure(闭包)

public class OutClass {

  private void helloWorld() {
    System.out.println("Hello World!");
  }

  public InnerClass getInnerClass() {
    return new InnerClass();
  }

  public class InnerClass {
    public void hello() {
      helloWorld();
    }
  }

  /**
   * @param args
   */
  public static void main(String[] args) {
    // 在外部使用OutClass的private方法
    new OutClass().getInnerClass().hello();
  }
}

在Java中有很多方式实现上述目的,因为我们的作用域和JS有着巨大差异。但是借鉴闭包的原理,我们来看一个场景。假设接口A有一个方法m;接口B也有一个同名的方法m,两个方法的签名完全一样但是功能却不一样。类C想要同时实现接口A和接口B中的方法。因为两个接口中的方法签名完全一致,所以C只能有一个m方法,这种情况下应该怎么实现需求呢?

public class C implements A {

  @Override
  public void m() {
    //...
  }

  private void o() {
    //...
  }

  public D getD() {
    return new D();
  }

  class D implements B {
    @Override
    public void m() {
      o();
    }
  }

  public static void main(String[] args) {
      C c = new C();
      c.m();
      c.getD().m();
  }
}

2.Currying(柯里化)

我对柯里化(Currying)的理解:柯里化函数可以接收一些参数,接收了这些参数之后,该函数并不是立即求值,而是继续返回另一个函数,刚才传入的参数在函数形成的闭包中被保存起来,待到函数真正需要求值的时候,之前传入的所有参数都能用于求值。

下面先通过JS(个人感觉通过JS比较好理解)对柯里化有一个直观的认识。

var calculator = function(x, y, z){
    return(x + y)* z;
}

调用:calculator( 2, 7, 3);

柯里化写法:

var calculator=function(x){
  return function(y){
    return function(z){
      return(x + y)* z;
    };
  };
};

调用:calculator(2)(7)(3);

通过对比,我们发现柯里化的数学描述应该类似这样,calculator(2, 7, 3) ---> calculator(2)(7)(3)。

现在我们来回头看看柯里化较为学术的定义,是把接受多个参数的函数变换成接受一个单一参数的函数,并且返回接受余下的参数的新函数,这个新函数最后还能返回所有输入的运算结果。

Java 中的柯里化实现

Function<Integer, Function<Integer, Function<Integer, Integer>>> currying =
    new Function<Integer, Function<Integer, Function<Integer, Integer>>>() {

    @Override
    public Function<Integer, Function<Integer, Integer>> apply(Integer x) {
        return new Function<Integer, Function<Integer, Integer>>() {

            @Override
            public Function<Integer, Integer> apply(Integer y) {

                return new Function<Integer, Integer>() {
                    @Override
                    public Integer apply(Integer z) {
                        return (x + y) * z;
                    }
                };
            }
        };
    }
};

//在这里,我们可以发现,虽然依次输入2、7,但是我们并不会计算结果,而是等到最后输入结束时才会返回值。
Function function1 = curryingFun().apply(2);//返回的是函数
Function function2 = curryingFun().apply(2).apply(7);//返回的是函数
Integer value = curryingFun().apply(2).apply(7).apply(3);//参数全部输入,返回最后的值

柯里化的争论

(1)支持的观点

  • 延迟计算,只有在最后的输入结束才会进行计算;

  • 当你发现你要调用一个函数,并且调用参数都是一样的情况下,这个参数就可以被柯里化,以便更好的完成任务;

  • 优雅的写法,语义更有表达力;

(2)不过也有一些人持反对观点,参数的不确定性、排查错误困难。

3.Promise

Promise 是异步编程的一种解决方案,比传统的诸如“回调函数、事件”解决方案,更合理和更强大。ES6已经广泛应用。我在这里主要分析两个最常见的用法。

  • then

Promise实例生成以后,可以用then方法分别指定resolved状态和rejected状态的回调函数。then方法返回的是一个新的Promise实例(注意,不是原来那个Promise实例)。因此可以采用链式写法,即then方法后面再调用另一个then方法。

promise.then(function(value) {
 // success
}, function(error) {
 // failure
}).then(...);
  • all

Promise.all方法用于将多个 Promise 实例,包装成一个新的 Promise 实例。

const p = Promise.all([p1, p2, p3]);

上面代码中,Promise.all方法接受一个数组作为参数,p1、p2、p3都是 Promise 实例,p的状态由p1、p2、p3决定,分成两种情况。

  • 只有p1、p2、p3的状态都变成fulfilled,p的状态才会变成fulfilled,此时p1、p2、p3的返回值组成一个数组,传递给p的回调函数。

  • 只要p1、p2、p3之中有一个被rejected,p的状态就变成rejected,此时第一个被reject的实例的返回值,会传递给p的回调函数。

下面是一个具体的例子:

// 生成一个Promise对象的数组
const promises = [1,2,3.....].map(function (id) {
  return getJSON('/post/' + id + ".json");
});

Promise.all(promises).then(function (posts) {
  // ...
}).catch(function(reason){
   // ...
});

Java的实现

Java中的使用方法目前确实不如js方便,可以看看CompletableFuture,给我们提供了一些方法。

4.Partial Function

其定义如下:当函数的参数个数太多,可以创建一个新的函数,这个新函数可以固定住原函数的部分参数,从而在调用时更简单。下面是基于Python的实现。个人觉得,最大的便利就是避免我们再去写一些重载的方法。不过暂时没有看到partial的Java版本。看到这里,大家肯定认为“偏函数”这个翻译实在是不准确,如果直译过来叫“部分函数”好像也不怎么清晰,我们姑且还是称其为Partial Function。

# !/usr/bin/python
# -*- coding: UTF-8 -*-
from functools import partial
def multiply(x, y):
  return x * y
print(multiply(3,4))# 输出12

multiply4 = partial(multiply, y =4)# 不需要定义重载函数
print(multiply4(3))# 输出12

5.map/reduce

Java现在对map、reduce也做了支持,特别是map已经是大家日常编码的利器,相信大家也都不陌生了。map(flatMap)按照规则转换输入内容,而reduce则是通过某个连接动作将所有元素汇总的操作。但是在这里我还是使用Python的例子来进行阐述,因为我觉得Python看起来更简洁明了。

# !/usr/bin/python
# -*- coding: UTF-8 -*-
from functools import reduce

def addTen(x):
    return x + 10

def add(x, y):
    return x + y

r = map(addTen, [1, 2, 3, 4, 5, 6, 7, 8, 9])
print r  #[11, 12, 13, 14, 15, 16, 17, 18, 19]
total = reduce(add, r)
print total #[11, 12, 13, 14, 15, 16, 17, 18, 19]加和等于135

6.divmod

divmod是Python的函数,我之所以专门来讲述,是因为它所代表的思想确实新颖。函数会把除数和余数运算结果结合起来返回,如下。不过Java肯定不支持。

//把秒数转换成时分秒结构显示
def parseDuration( seconds ):
    m, s = divmod(int(seconds), 60)
    h, m = divmod(m, 60)
    return  ("%02d:%02d:%02d" % (h, m, s))

三、关于Scala

上述很多特性,Scala都提供了支持,它集成了面向对象编程和函数式编程的一些特性,感兴趣的同学可以了解一下。之前看过介绍,Twitter对于Scala的应用比较多,推荐阅读 Twitter Effective Scala 。

四、结语:我们为什么要学习函数式编程

在很多时候,无可否认命令式编程很好用。当我们写业务逻辑时会书写大量的命令式代码,甚至在很多时候并没有可以归纳抽离的实现。但是,如果我们花时间去学习、发现可以归纳抽离的部分使其朝着声明式迈进,结合函数式的思维来思考,能为我们的编程带来巨大的便捷。

通过其他语言来触类旁通函数式编程的奇技淫巧,确实能带给我们新的视野。我相信随着机器运算能力不断提升、底层能力更加完善,我们也需要跳出如何做的思维限制,更多地站在更高的抽象层去思考做什么,方能进入一个充满想象、神奇的computable world。

返回开发技术教程...