函数式编程简介

函数式编程是什么

函数式编程是一种编程范式,大部分人已知的编程范式有C的命令式,Java的面向对象,我们不需要从学术的角度,来严格定义函数式编程,并且函数式和面向对象也不是非此即彼的关系,强大如Scala也是混合式范式语言,我们从实用角度来介绍和解读函数式编程。
函数式编程至少应该具有以下特性:

  • 函数是一等公民
  • 支持匿名函数
  • 闭包
  • 柯里化
  • 惰性求值
  • 参数多态
  • 代数数据类型
  • 。。。

函数式编程理所当然是以函数为主要编程对象,而面向对象则以对象为主要编程对象,但是你仔细想想,面向对象如果没有方法,而只是有一堆属性的类,哪还有什么功能价值呢?所以即便是面向对象也离不开方法,当然这里的方法除了不是一等公民以外,和函数式编程差别也很大,那差别在哪里了?

纯函数

纯函数,又称为没有副作用,或者引用透明,那什么样的方法是没有副作用的呢?

  • 方法的参数值不变,则返回值就不会改变,比如random函数就不是纯函数。
  • 必须有返回值。
  • 方法不会对外界变量造成改变,即便是打印日志,控制台输出,都不可以。
  • 不能抛出异常,即便是抛出异常也不是纯函数。

纯函数有什么好处呢?

  • 独立性,因为不会依赖外部变量,只依赖输入的参数,很方便进行单元测试。
  • 确定性,相同的参数,总是返回相同的结果,不会出现诡异的bug。
  • 安全性,不用担心它抛出异常,函数调用等价于表达式。
  • 结合高阶函数,稳定的纯函数复合出来的函数依然具备以上特性。

手写纯函数

我们下面使用Java来写一个纯函数,虽然Java它并不是函数式的语言,但是并不妨碍我们展示函数式编程,有些语言对函数式编程比较友好,比如Scala,但是函数式编程并不仅仅取决于语言,而是取决于你怎么写代码。
自从Java8开始支持lambda表达式开始,Java已经有点函数式的味道了。我们先从一个接口开始,因为Java里函数不是一等公民,所以我们只能从接口定义函数。

public interface Function<T, U> {
    U apply(T arg);
}
复制代码

我们来做一下单元测试

public class FunctionTest {
    private final Function<Integer, Integer> add = x -> x + 1;
    private final Function<Integer, Integer> multi = x -> x * 2;
    
    @Test
    public void testApply() {
        assertEquals(Integer.valueOf(2), add.apply(1));
        assertEquals(Integer.valueOf(4), multi.apply(2));
    }
}
复制代码

add和multi就是纯函数。这个apply的定义虽然看起来很基础简单,但是就像函数式的基石一样,非常有用。

高阶函数

什么是高阶函数呢?别被高阶给吓到,其实就是可以接收函数作为参数,并且可以返回函数的函数。

public interface Function<T, U> {
    U apply(T arg);

    default <V> Function<V, U> compose(Function<V, T> f) {
        return v -> apply(f.apply(v));
    }
}
复制代码

来看单元测试

public class FunctionTest {
    private final Function<Integer, Integer> add = x -> x + 1;
    private final Function<Integer, Integer> multi = x -> x * 2;

    @Test
    public void testApply() {
        assertEquals(Integer.valueOf(2), add.apply(1));
        assertEquals(Integer.valueOf(4), multi.apply(2));
    }

    @Test
    public void testCompose() {
        Function<Integer, Integer> compose = add.compose(multi);
        Integer result = compose.apply(1);
        assertEquals(Integer.valueOf(3), result);
    }
}
复制代码

add组合函数multi生成了新的函数compose,调用该函数得到返回值,其实就是执行了1+1*2 = 3
对函数式编程有点感觉了吗?这才只是开胃菜,让我们继续烧脑。

柯里化

函数是可以有多个参数的,以2个参数的举例,f(x,y) = x+y*2 有时候我们并不想一次性把2个参数都确定,而只是固定一个参数,以固定的这个参数为函数,再应用另外一个参数,比如我们先固定x=1,然后再应用这个得到的函数y=2,y=3,就可以得到一个对参数加倍再固定+1的函数。
但是,我们之前定义的apply方法只有一个参数怎么办?其实把函数看成只有一个参数,引入Tuple类型,先看下Tuple的定义

public class Tuple<T, U> {
    public final T _1;
    public final U _2;

    public Tuple(T t, U u) {
        this._1 = t;
        this._2 = u;
    }
}
复制代码

很简单,就是参数的盒子而已,这样我们就可以使用之前定义的函数来传入2个参数了。

static <A, B, C> Function<A, Function<B, C>> curry(Function<Tuple<A, B>, C> f) {
       return a -> b -> f.apply(new Tuple<>(a, b));
}
复制代码

单元测试如下

private final Function<Tuple<Integer, Integer>, Integer> addWithTuple = t -> t._1 + t._2 * 2;
@Test
public void testCurry() {
    Integer result = Function.<Integer, Integer, Integer>curry(addWithTuple).apply(1).apply(2);
    assertEquals(Integer.valueOf(5), result);
}
复制代码

惰性求值

惰性求值是指只有在真正获取的时候,才去执行函数,举个栗子

@Test
public void testLazy() {
    add(1, recursion(1));
}

private Integer recursion(Integer b) {
    return recursion(b);
}

private Integer add(int a, int b) {
    return a;
}
复制代码

执行上面的代码,就会得到java.lang.StackOverflowError,我们明明不需要b的结果,但是由于Java表达式并不支持惰性,所以导致递归函数一直执行到爆栈。
我们来改造一下。

@Test
public void testLazy() {
    add(1, () -> recursion(1));
}

private Integer recursion(Integer b) {
    return recursion(b);
}

private Integer add(int a, Supplier<Integer> b) {
    return a;
}
复制代码

好吧,我承认我作弊,改变了参数类型,但是这个例子只是让你感觉一下,什么是惰性求值。

模式匹配

函数式最有用的一个功能,我觉得就是模式匹配,因为可以很好的消除if else,命令式往往充斥着if else,而这些往往是令人难以理解和产生bug的地方,因此难于维护,而函数式编程里,只有表达式没有语句,if else往往纠结在细节里,而函数式编程通过代数数据类型,以及解构对象来对表达式进行匹配,可以很优雅的解决if else的问题。下面我们来演示一下如何消除if else,这次因为Java表达能力不够简洁,我们使用kotlin来做演示

需求

  • 计算个人所得税
  • 如果一个人有房子则减免100再计税
  • 如果一个人有孩子则减免200再计税
  • 减免后大于等于1000则缴税减免后金额的3%
  • 减免后大于等于3000则缴税减免后金额的5%
  • 求应交税多少,不考虑精度丢失等情况

过程式函数实现


 private double tax(boolean hasHouse, boolean hasChild, double money) {
        double firstLevel = 1000;
        double firstLevelReduce = 100;
        double secondLevel = 3000;
        double secondLevelReduce = 200;
        double tax = 0;
        double after = money;
        if (hasHouse) {
            if (hasChild) {
                after = money - secondLevelReduce - firstLevelReduce;
            } else {
                after = money - firstLevelReduce;
            }
        } else {
            if (hasChild) {
                after = money - secondLevelReduce;
            }
        }
        if (after >= firstLevel) {
            tax = after * 0.03;
        }
        if (after >= secondLevel) {
            tax = after * 0.05;
        }
        return tax;
 }
复制代码

过程式避免不了if else的嵌套,即便重构基于设计模式的优化,比如策略或者责任链,杀鸡用牛刀不说,扩展性好了,但是可读性差了。
看一下使用kotlin的版本


data class Person(val hasHouse: Boolean, val hasChild: Boolean, val money: Double) {
    private var actualReduce: Double = when (Pair(hasHouse, hasChild)) {
        Pair(true, true) -> HAS_HOUSE_REDUCE + HAS_CHILD_REDUCE
        Pair(true, false) -> HAS_HOUSE_REDUCE
        Pair(false, true) -> HAS_CHILD_REDUCE
        else -> 0.0
    }

    companion object {
        private const val FIRST_LEVEL = 1000.0
        private const val FIRST_LEVEL_RATE = 0.03
        private const val SECOND_LEVEL = 3000.0
        private const val SECOND_LEVEL_RATE = 0.05
        private const val HAS_HOUSE_REDUCE = 100.0
        private const val HAS_CHILD_REDUCE = 200.0
    }

    fun tax(): Double {
        return when (val after = money - actualReduce) {
            in FIRST_LEVEL..SECOND_LEVEL -> {
                after * FIRST_LEVEL_RATE
            }
            in SECOND_LEVEL..Double.MAX_VALUE -> {
                after * SECOND_LEVEL_RATE
            }
            else -> {
                0.0
            }
        }
    }

}
复制代码

虽然有点不公平,因为使用了类,但是其实逻辑都差不多,关键还是使用了when模式匹配,以及after的表达式匹配
虽然你说when case不就是switch么,其实不是,因为模式匹配可以匹配类型,表达式等等,而switch只能是值(整数或字符串)。
最关键的是可读性提高了,不需要注释说明,即可知道代码所表达的逻辑,接近自然语言。

结束

当然函数式还有很多没有介绍的,比如Typeclass,函数式的集合,集合的折叠fold,map,flatMap等等,以及函数式范畴学的Monad
但是咱只是简介嘛,所以以后有机会再深入介绍吧。