什么是函数式编程

493 阅读8分钟

1 什么是函数式编程

一个方法可以是函数式的,只要它满足纯函数的要求

  1. 它不能修改函数外的任何东西。外部观测不到内部的任何变化。
  2. 它不能修改自己的参数。
  3. 它不能抛出错误或异常。
  4. 它必须返回一个值。
  5. 只要调用它的参数相同,结果也必须相同。

看一个例子:

public class FunctionMethods {

    public int percent1 = 5;

    private int percent2 = 5;

    private final int percent3 = 5;

    public void setPercent2(int percent2) {
        this.percent2 = percent2;
    }

    public int add(int a, int b) {
        return a + b;
    }

    public int mult(int a, Integer b) {
        a = 5;
        b = 2;
        return a * b;
    }

    public int div(int a, int b) {
        return a / b;
    }


    public int tax1(int a) {
        return a / 100 * (100 + percent1);
    }

    public int tax2(int a) {
        return a / 100 * (100 + percent2);
    }

    public int tax3(int a) {
        return a / 100 * (100 + percent3);
    }

    public List<Integer> append(int i, List<Integer> list) {
        list.add(i);
        return list;
    }
}

add方法是不是一个函数呢? add是一个函数,因为它返回的值总是取决于自己的参数。它并不修改自己的参数,也不以任何方式与外界交互。这个方法可能会在a+b溢出最大的int值时出错,但它并不会抛出异常。结果是错误的(一个负数),但这是其他问题。每当函数被相同的参数调用,结果必须总是一致的。

mult方法是不是一个函数呢? mult方法也是一个纯函数,但是和add方法不一样的是,其内部修改了参数a,b的值啊,但是java的基本类型是值传递的,意味着在函数内部对参数的修改,外部是无法探测的。这个方法等价于一个无参方法。

// java的基本类型是值传递的,意味着在函数内部对参数的修改,外部是无法探测的
public static void main(String[] args) {
    FunctionMethods functionMethods = new FunctionMethods();
    int a = 5;
    int b = 7;
    System.out.println(functionMethods.mult(5, 7));
    // a 还是 5,b还是7
    System.out.println("a -> " + a);
    System.out.println("b -> " + b);
}

div方法是不是一个函数呢? 不是一个函数,由于除数为0的时候会抛出异常,所以div方法并不是一个纯函数。为了让它成为函数,你可以检查第二个参数,当其为0时返回一个值。

tax1方法是不是一个函数呢? 不是一个函数,因为依赖外部的一个公共(public 修饰)的变量,而这个值在两次调用间可能会不一致。因此,相同参数的两次调用可能会返回不同的值。

public static void main(String[] args) {
    FunctionMethods functionMethods = new FunctionMethods();
    System.out.println(functionMethods.tax1(100));
    // 修改percent1的值
    functionMethods.percent1 = 10;
    // 相同参数,返回值不一样
    System.out.println(functionMethods.tax1(100));
}

tax2方法是不是一个函数呢? 不是一个函数,虽然percent2是一个私有的,但是提供了一个公共的set方法可以修改其值,所以和tax1一样也不是一个函数

tax3方法是不是一个函数呢? 是一个函数,在参数相同的情况下,这个方法总是会返回相同的值,因为它只依赖于自己的参数和percent3这个不可变的final属性。 你可能会觉得tax3不是一个纯函数,因为结果并不只是依赖于方法的参数(纯函数的结果必须只依赖于自己的参数)。 其实percent3可认为是一个额外参数。 其实这个类本身也可以被认为是一个隐式的额外参数,因为在这个方法内部可以访问它的所有属性。

这是一个很重要的观念。所有的实例方法都可以通过在参数里增加外围类(enclosing class)的类型而变成一个静态方法。比如这个tax函数可以改写为:


public int tax3(int a) {
    return tax3(this,a);
}
// 这个方法可以从类内部被调用,传一个this的引用作为参数
public static int tax3(FunctionMethods functionMethods, int a) {
    return a / 100 * (100 + functionMethods.percent3);
}

append方法在返回结果之前改变了参数,而这个改变可在方法外界被观测到,所以它不是一个纯函数。

2 函数式编程的优势

  1. 函数式程序更加易于推断,因为它们是确定性(deterministic)的。对于一个特定的输入总会给出相同的输出。在许多情况下,你都可以证明程序是正确的,而不是在大量的测试后仍然不确定程序是否会在意外的情况下出错。
  2. 函数式程序更加易于测试。因为没有副作用,所以你不需要那些经常用于在测试里隔离程序及外界的mock。
  3. 函数式程序更加模块化,因为它们是由只有输入和输出的函数构建的。我们不必处理副作用,不必捕获异常,不必处理上下文变化,不必共享变化的状态,也没有并发的修改。
  4. 函数式编程让复合和重新复合更加简单。为了编写函数式程序,你需要开始编写各种必要的基础函数,并把它们复合为更高级别的函数,重复这个过程直到你拥有了一个与你打算构建的程序一致的函数。因为所有的函数都是引用透明的,它们无须修改便可以为其他程序所重用。

3 用代换模型来推断程序

一个函数什么事情都不做,它只是有一个值,依赖于参数而已。因此,永远都可以用其值来替换一个函数调用或是任何引用透明的表达式

20210507103433873.png 看一下这段代码

public static int add(int a, int b) {
    System.out.println(String.format("Returning %s as result of %s + %s", a + b, a, b));
    return a+b;
}

public static int mult(int a, int b){
    return a * b;
}

public static void main(String[] args) {
    int result = add(mult(2,3),mult(4,5));
}

在调用add方法的时候,是不是可以完全把add(mult(2,3),mult(4,5))替换为add(6,20),将mult(2,3)和mult(4,5)替换为它们各自的返回值并不会改变程序的含义,程序的执行不会结果没有任何变化

但是如果把add函数直接替换为返回值就改变了程序的含义,因为System.out.println方法将不再被调用,从而不会记录任何日志。这可能很重要,也可能不是。总而言之,它改变了程序的结果。

4 将函数式原则应用于一个简单的例子

用信用卡购买一个面包的小例子

public class BreadShop {

    public Bread buyBread(CreditCard creditCard){
        Bread bread = new Bread();
        // 信用卡支付,在这里就是个副作用
        creditCard.charge(bread.getPrice());
        return bread;
    }
    public static class Bread {
        public Double getPrice(){
            return 100D;
        }
    }
}

信用卡支付是一个副作用。信用卡支付多半由调用银行、检查信用卡是否可用并已授权、注册交易等组成。函数返回面包

这种代码的问题在于难以测试。测试程序可能需要联系银行并用某个mock账户来注册交易。要不就得创建一张mock信用卡来代替真实的charge方法,并在测试之后验证mock的状态。

如果你想在无须接触银行或是使用mock的情况下测试代码,那就应该移除副作用。由于你仍然想要用信用卡支付,唯一的解决方案就是往返回值里加个什么东西来表示这个操作。你的buyBread方法将会返回支付和表示支付的这个东西(Payment)。

public class Payment {

    private final CreditCard creditCard;

    private final Double amount;

    public Payment(CreditCard creditCard, Double amount) {
        this.creditCard = creditCard;
        this.amount = amount;
    }
    
    public void pay() {
       this.creditCard.charge(amount);
    }
}

这个类包含了表示支付的必要数据,由一张信用卡和支付金额组成。由于buyBread方法需要返回Donut和Payment两个对象,你需要为此创建一个专门的类

public class Purchase {

    public BreadShop.Bread bread;

    public Payment payment;

    public Purchase(BreadShop.Bread bread, Payment payment) {
        this.bread = bread;
        this.payment = payment;
    }
    
}

经常会需要一个类来容纳两个(或以上的)值,因为函数式编程替换副作用的方式就是将其返回。

测试修改买面包的方法

public Purchase buyBread2(CreditCard creditCard){
    Bread bread = new Bread();
    return new Purchase(bread,new Payment(creditCard,bread.getPrice()));
}
public void testBuyBread2(){
    // 此时测试调用buyBread2方法就不需要考虑信用卡支付的问题
    // 完全可以在后面调用信用卡的功能,甚至可以不调用,完全不影响buyBread2的测试
    Purchase purchase = new BreadShop()
            .buyBread2(new CreditCard());
    // 在这里进行立即支付,或者将purchase保存起来,后面在支付
    purchase.payment.pay();
}

在这个时候你不必顾虑如何真正地用信用卡支付,这样可以给你构建程序带来一些自由。你仍然可以接着立即支付,也可以将它保存起来以便后续支付。

甚至还可以将一张信用卡里保存的多份待支付记录合并起来,在一个操作里处理完成。这样便可以通过减少调用信用卡服务的次数来节省你的开销,比如通过下面的combine方法就可以合并一个Payment

public class Payment {

    public Payment combine(Payment payment) {
        if(payment.creditCard.equals(this.payment)){
            // 如果是一个信用卡账号,那就可以将两次的支付金额合并起来
            // 返回一个新的Payment
            return new Payment(this.creditCard,payment.amount + this.amount);
        }
        // 如果不是同一个支付账号,那么就不能合并抛出异常
        throw new IllegalStateException("current creditCard not match");
    }
}

请注意,如果信用卡不一致,将会抛出异常。这与函数式编程不抛出异常并不矛盾。在这里,试图合并两张不同信用卡的两份待支付记录,被视为一个bug,所以必须使应用程序崩溃。当然这个处理方式不是很明智,后面会介绍更好的处理方式

@Test
public void testBuyBread3(){
    BreadShop breadShop = new BreadShop();
    CreditCard creditCard = new CreditCard();
    // 第一次购买
    Purchase purchase1 = breadShop.buyBread2(creditCard);
    // 第二次购买
    Purchase purchase2 = breadShop.buyBread2(creditCard);

    // 合并支付
    purchase1.payment.combine(purchase2.payment).pay();
}