你需要知道的有关泛函编程的一些知识 —— 代数类型与类型匹配

我们知道在面向对象编程中有这么一条军规:向修改说No,向扩展说Yes。基于这条军规,我们实现了“继承”。客观地说,当我们从具体功能的角度审视这条军规时,它在大多数情况下都能良好地发挥作用,但是如果我们将视角从具体的功能实现提升到基于类型安全的角度,那么这条军规依然能够完美地约束我们对多态的要求吗?让我们来看一个简单的例子:

假设我们要实现一个“交通灯”应用,基于这条军规我们可能会先实现一个名为 Light的接口(或抽象基类),然后通过扩展这个接口(或基类)实现“RedLight”,“YellowLight”和“GreenLight”三个子类分别代表“红”“黄”“绿”三种灯:

public interface Light {
    void on();
    void off();
}

class RedLight implements Light{
    @Override
    public void on() {
        System.out.println("Stop");;
    }

    @Override
    public void off() {
        System.out.println("Red off");
    }
}

class YellowLight implements Light{
    @Override
    public void on() {
        System.out.println("Attention");
    }
    @Override
    public void off() {
        System.out.println("Yellow off");
    }
}

class GreenLight implements Light{
    @Override
    public void on() {
        System.out.println("Permit");
    }
    @Override
    public void off() {
        System.out.println("Green off");
    }
}

接下来我们通常会编写一个信号灯控制器(LightController)来控制灯的点亮和熄灭:

class LightController{
    void signal(Light light){
        if (light instanceof RedLight) {
            ((RedLight)light).on();
        } else if(light instanceof YellowLight) {
            ((YellowLight)light).on();
        } else if(light instanceof GreenLight) {
            ((GreenLight)light).on();
        } else {
            throw new RuntimeException("Unknown light type");
        }
    }
}

从这段代码中我们可以看到几个明显的问题:首先:我们需要在强制转换之前先使用 instanceof 关键词来判断当前Light的子类型。因为在OO中,我们只被允许安全地向“上”,而不是向“下”进行类型的转换,当我们要将一个类型转换到它的子类型时,我们就必须自己完成类型检查,而编译器无法对我们行为的安全性负责。这就是继承带来的第一个局限性。

那么有没有一种方法使得我们的类型也可以向下负责呢?也就是说,我们是否可以让编译器通过有限的枚举来自动识别出正确的子类型呢?从传统的OO原理出发,因为继承的开放性,也就是我们之前提到的那条军规,让我们无法将Light的子类限定在仅仅只有这三种可选项内,虽然从常识上我们可能永远也不会去尝试实现一个粉色的交通灯,但是编译器并不具备这个常识,因此它不可能为我们实现这种我们所期待的类型检查。甚至,为了防止在未来某种未知的状态下发生低级错误,我们可能还需要给 if…elseif…语句块在最后加上一个else来为所有未知的情形提供处理。这就犹如脱裤子放屁一样,但是却是必须的。由此可见如何依据父类型对子类型实现安全检查,这就是我们要解决的由OO带来的第一个问题。

在离散代数中我们知道有一个“集合”的概念,一个集合是所有满足定义域的解的全集,所以如果我们把接口(或基类)视为某个定义域,而它可能的子类视为它的“解”的话,那么我们就会得到这样一个代数式:

Light ={RedLight, YellowLight, GreenLight}

所以如果我们能够实现一个类,它的子类只能是花括号中的三种,那么我们也就能够告诉编译器它可以通过枚举这三种子类型来找到我们所需要的类型。因此在泛函编程中,我们引入了一个名为“和(sum)类型”的概念,顾名思义,也就是说,某个类型是它所有子类型的全集。在Scala 中,我们用 sealed 关键词来标识这个类,于是我们得到以下代码:

sealed trait Light

final case class RedLight(status:Boolean) extends Light
final case class YellowLight(status:Boolean) extends Light
final case class GreenLight(status:Boolean) extends Light

在这样一个实现中 sealed trait Light 被明确限定为其下三种子类型的父类,它不可能在别的地方被继承,并且它的子类也不能被继承,也就是说它的所有可能的“解”都被封印了起来,于是我们可以安全地使用 match…case 来进行类型匹配了:

object LightController {
    def signal:Light => Unit = {
        case RedLight(true) => ???
        case YellowLight(true) => ???
        case GreenLight(true) => ???
    }
}

并且,因为编译器已经得到足够的信息来保证Light的子类型不会超出以上三种,因此这三个case已经完全覆盖了所有的可能性,我们也没必要再实现一个 default来处理超出期待的情况。这样的代码不仅安全而且简洁明了。

第二个问题是:我们知道交通灯的信号组合永远只可能有一盏灯是处于点亮的状态,因此在实现的过程中,我们必须考虑他们的组合以防止不合理的状态出现,那么我们能否让编译器为我们检查所有的排列组合呢?很遗憾,回答依然是做不到,因为如果我们要让编译器检查每一种组合,那么我们需要通过某种方式明确地告知编译器所有可能的参数和它们的形式,而没有一种传统手段可以做到这一点,因此我们只能自行在程序中通过if…else…来枚举所有的情况,如果稍有不慎漏掉了某种情况,那么编译器是不会发现错误的,等待你的也许是灾难性的结果。

但是在离散代数中,我们知道在一个确定的数据全集中,其中的任何一个或多个成员的组合都是这个全集的子集。而一个非无穷全集中的子集是可以穷举的!基于这一点,我们以RedLight这个类为例,它的数据公民有两个,分别是true和false,并且它们出现在子集中的机会是排它性的,因此它可能的结果也就只有两个。如果我们进一步将组合扩大到三个灯,那么我们就可以得到他们不同的组合,因此现在我们只需要通过某种方式将这个组合告诉编译器就可以,答案就隐藏在 case class 中,我们只需要定义一个包含全部三个类型的灯的LightController case class就可以做到这一点:

case class LightController(r:RedLight, y:YellowLight, g:GreenLight)

这个LigjtController类包含了三个参数,现在编译器只需要观察这三个参数(包括递归每个参数的两种结果)就可以覆盖所有可能的组合情况,因此如果我们有以下代码:

object LightController{
    def signal: LightController => Unit = {
        case LightController(RedLight(true), YellowLight(false), GreenLight(false)) => println("Stop")
        case LightController(RedLight(false), YellowLight(true), GreenLight(false)) => println("Attention")
        case LightController(RedLight(false), YellowLight(false), GreenLight(true)) => println("Permit")
    }
}

编译器就可以给出警告我们的代码是不完整的,因为很显然我们只枚举了三种合理的情况,除非我们提供一个缺省的方法来覆盖全部情况:

object LightController{
    def signal: LightController => Unit = {
        case LightController(RedLight(true), YellowLight(false), GreenLight(false)) => println("Stop")
        case LightController(RedLight(false), YellowLight(true), GreenLight(false)) => println("Attention")
        case LightController(RedLight(false), YellowLight(false), GreenLight(true)) => println("Permit")
        // All the other cases
        case _ => println(Wrong combination?")
    }
}

与“和类型”一样,这样的 case class 也有一个称呼叫做“积(product)类型”,顾名思义就是它表示一个集合中所有解的“积”结果。而对应不同的“积”的处理函数称为“偏函数”,这个概念和代数中的偏函数是一致的。 由于以上的“和类型”和“积类型”都来自代数,因此它们被统称为“代数类型”。代数类型在泛函编程中发挥了重要的作用,尤其在类型安全领域,它弥补了OO的缺憾,为继承带来更安全的编程体验。可喜的是今天的Java也逐步接受了代数类型,在最新的Java14中已经出现了 Record类,其实就是积类型,而在即将发布的Java15中也将引入sealed关键词用于实现和类型。我们很高兴看到Java正在一步一步走向OO和FP的结合,虽然这一天的到来有点慢。

Leave a Reply
Your email address will not be published.
*
*

BACK TO TOP