首页 编译原理

原文:What is function programming
作者:Kris Jenkins
翻译:yelbee

这是我对函数式编程的理解,我将会以一种通俗易懂的方式让只想把活干完的打零工的程序员们能够理解。

如果我说,每个函数都有两组输入和输出,你肯定会怀疑,两组?难道不是只有一组吗?

不,确实是两组,让我们来看一个例子吧:

public int square(int x) {
    return x * x;
}

这里的函数的输入是int x而输出也是一个int类型的数,这是第一个例子的输入和输出,它只有一个输入和一个输出。现在让我们来再看第二个例子:

public void processNext() {
    Message message = InboxQueue.popMessage();

    if (message != null) {
        process(message);
    }
}

根据语法来看,这个函数既没有输入也没有输出,但是它显然依赖着什么东西,也完成某个具体的功能。事实上,这个函数隐藏了输入和输出。隐藏的输入是在InboxQueue调用的popMessage方法,而隐藏的输出是process处理message的结果。

InboxQueue的声明确实是函数真正的输入,如果不知道InboxQueue.popMessage()将会赋给message什么值,那么我们也不会知道processNext的行为是什么。而它同时也是函数的输出,在不知道InboxQueue的新状态的情况下,我们也不能得到函数processNext的运行结果。

所以对于第二段的示例代码,它隐藏了输入和输出。它需要参数,也会产生输出,但是你永远也不知道发生了什么仅仅查看它的API。

这些隐藏的输入和输出有一个专门的词汇来形容它:副作用,副作用的种类有许多,但是它们都有相同的特征:当我们调用这个函数时,它所需要的不在参数列表中,而它所做的不在返回值中。(实际上我认为应该需要两个术语:“副作用结果”描述隐藏的输出,“副作用原因”描述隐藏的输入。在接下来的文章中,为了简洁,我将会统一使用“副作用”来表示“副作用结果”和“副作用原因”。)

副作用是造成复杂性的冰山

当函数有副作用时,你可以看一下如下的函数:

public boolean processMessage(Channel channel) {...}

...你会想,啊,我知道它是做什么的。不好一次,大错特错了!如果我们不看函数的内部,我们永远也不知道它需要什么又做些什么。这个函数是从通道中获取信息,然后处理它吗?也许是。这个函数是判断某个条件,然后关闭通道吗?也许是。这个函数时用于更新数据库中某项的计数值吗?也许是。这个函数会在找不到它所需要的日志目录的路径时发生崩溃吗?也许会。

副作用是复杂性的冰山,你看到函数的类型和名称,你会有一种你什么都知道的错觉。函数的声明就像的冰山一角,但是隐藏在这下面的,完全可以是任何东西。任何隐藏的需求,任何隐藏的变化,不去看具体的实现代码,你将永远不会知道函数中都涉及哪些操作。而在API的表面之下,可能又是一大块更复杂的冰块。为了解决这个问题,你只有三个选择:

  • 潜入到函数的定义
  • 将函数中调用的函数展开,将复杂带到表面
  • 无视它,对着天祈祷一切好运

最后,如果你选择忽视它,那将是一个巨大的错误,你将会像泰坦尼克号一样,因为忽视冰山一角而付出沉重的代价。

这是难道不是封装吗?

不是。

封装是隐藏实现的细节,隐藏代码的内部细节所以调用者不必担心它们。这是一个很好的原则,但是这不是我们正在讨论的问题。

副作用不是掩藏实现的细节,而是将代码与外界的关系隐藏起来。一个有副作用的函数将不好确定它所依赖的外部因素,也不好观测它将要改变的外部条件。

副作用不好吗?

当我们按照最初的程序员预期的那样工作,那很好,很可能什么事也没有。但是这也就提出了一个谬论:我们不得不相信最初程序员蕴含在代码逻辑中的想法是正确的,并且随着时间的流逝依旧正确。

世界是否是按照我们编写函数所期待的方式来构建的?还是,这个世界无时不刻都在变化?也许,只是因为表面上一段无关联的代码的改变,只是因为我们在新的环境下安装软件。如果我们默许这种蕴含在代码中的预期假设总能描述世界的模式,也就意味着我们认为这个世界的状态总是相似的,我们所编写的函数足够胜任工作。

我们能够测试这段代码吗?可以,但是绝对不是以独立的方式。不像电路板的检测,我们不能简单地通过插拔插头的方式来检查代码的输入输出。我们必须打开代码,找到所有潜在的原因和结果,然后模拟这个代码所处的工作环境。我曾经看过一些测试驱动开发的工程师们围坐在一起商议他们应该进行黑盒测试还是白盒测试。而答案是,如果你想做黑盒测试,你应该要能够忽略实现的细节,但如果你允许副作用的存在,那么你将不能进行白盒测试。副作用的存在将黑盒测试拒之门外,因为不撬开盒子,研究里面的细节,你将不能得到输入和输出。

而这个问题在调试中会更重要。如果一个函数不允许副作用,通过给定的输入然后检查相应的输出,你便可以知道它是否正确。但是一个有副作用的函数?除了这个部分的代码,你还要考虑与其相关的其他部分,这可能会面临数不清的状况。当它被允许依赖于任何东西,并导致任何东西时,那么BUG可能出现在任何地方。

我们总是可以简化副作用

我们能为简化复杂性去做些什么吗?是的,这其实相当简单:如果一个函数需要输入,那么就把它说出来;如果一个函数以返回值作为输出,那么就声明出来。就是这么简单。

让我们来看一个例子,这是一个有隐藏输入的函数。快速地浏览下面一段代码,如果你能马上发现关键点,我会给你一个大大的奖励:

public Program getCurrentProgram(TVGuide guide, int channel) {
  Schedule schedule = guide.getSchedule(channel);

  Program current = schedule.programAt(new Date());

  return current;
}

这个函数有一个当前时间的隐藏输入(new Date()),我们可以直接将这个输入声明出来,这样我们就可以简化复杂程度了:

public Program getProgramAt(TVGuide guide, int channel, Date when) {
  Schedule schedule = guide.getSchedule(channel);

  Program program = schedule.programAt(when);

  return program;
}

这个函数现在没有隐藏的输入和输出了。

让我们来看一下这个新版本代码的优点和缺点:

缺点

它看起来更复杂。它有三个参数而不是两个。

优点

它并不复杂。隐藏依赖并不会使它更简单,所以实际上我们的新版本改进不会使函数变得更复杂。

它的测试要容易得多。测试一天中的不同时间,时钟变化以及闰年,都将是直截了当、清晰明了的,因为我们可以随时通过传入参数的方法进行测试。我在生产环境中见过类似于我们第一个版本的代码(也就是有隐藏输入的那个版本),在测试时有各种巧妙的技巧来欺骗当前系统时钟。想象一下,实际上我们稍加麻烦点,只要把它作为一个参数就可以避免这个问题!

我们可以更好地意识到这样做的理由:这个函数现在被描述成输入和输出的关系。如果你知道输入,你就会知道它的结果应该是什么,甚至关于结果的任何相关的东西。这简直就是一桩白赚不亏的生意。我们可以独立地去验证这段代码了。只要我们测试了输入和输出之间的关系,我们就测试了整个函数。

(顺便提一下,这样做也是相当有用的。我们现在看审视一下新版本的代码,我们可以知道这段代码实现了一个这样的功能,“在接下来的某个时间什么节目会播出”)

什么是“纯函数”

鼓声响起,注意力集中啦!

现在对隐藏的输入和输入有了认识后,现在让我们来对纯函数下一个通俗易懂的定义:

一个函数所有的输入和输出都显式地声明出来,而不是隐藏起来,这样的函数我们称为纯函数

相反地,如果一个函数有隐藏的输入和输出,那么它是“不纯的”,而我们从函数中能获取的信息是不完整的,就像是复杂性的冰山若隐若现。我们不能独立的使用不纯的代码,不能独立地测试它。无论我们何时对它进行测试,它都依赖着我们不能把控的东西。

什么是函数式编程语言?

任何函数都提供了纯函数——对于add(x,y)很难让它是不纯的。在很多情况下,将不纯的函数转换为纯函数只需要将所有内层的输入和输出提取出来,都放在最表层的函数里,这样就可以直接在最表层的函数里完全地描述函数的行为。所以,所有的编程语言都是“函数式”的吗?

不,否则这个术语就没有意义了。

我们要如何给函数式编程语言下一个通俗易懂的定义?

具体来说:函数式编程语言会在任何可能的情况下主动帮助你消除副作用,而在做不到时会严密地控制它们。

或者更戏剧性的来说:函数式编程语言仇视副作用。副作用是复杂的,而复杂性是BUG,而BUG就是地狱。一个函数式编程语言会帮助你以副作用为仇敌。你俩一同合力让副作用乖乖缴械投降。

真的是这样吗?

没错就是这样。这里有一些微妙之处——你之前可能没有想过隐藏的输入这样的问题,但是这实际上这就是本质。开始用“副作用是首当其冲的敌人”的观念开始构建软件架构吧,这会改变你所知道的关于编程的一切。跟我一起阅读第二部分《什么编程语言是函数式的》,在那里我们将会讨论到副作用、函数式编程,对编程有一个全新的理解。




文章评论

captcha