【Programming】函数式编程(Functional Programming)

Posted by 西维蜀黍 on 2018-02-23, Last Modified on 2021-09-21

1 背景

**函数式编程(functional programming)**开始获得越来越多的关注。

2 定义

**函数式编程(functional programming)**的函数是指数学上的函数:给定输入固定的输出,没有副作用。

其主要思想是把运算过程尽量写成一系列嵌套的函数调用。


函数式编程是**声明式编程(declarative programming)**的一种,主要思想是只需要一步一步地表达计算的逻辑,而不需要关注具体的内部计算逻辑。

声明式语言包括SQLXQuery。比如在SQL,我们只需要告诉SQL Engine我们需要什么数据(select)、我们想要如何修改数据(增删改),却不需要具体去描述如何获取这些数据,如果实现数据的修改。SQL Engine会自动帮我们完成这些工作。

声明式编程的对立面是命令式编程(imperative programming),命令式编程与过程式编程(Procedural programming)是几乎等同的概念。

声明式编程与命令式编程的主要区别是,后者在完成计算时,需要显式的指明每一步的计算方法(而前者仅仅需要指明做何计算)。

一句话概括,声明式编程只需要说明做什么,而命令式编程需要一步一步描述怎么做

Further Reading: Functional Programming vs. Imperative Programming (C#)


函数式编程是与面向对象编程(Object-oriented programming)和过程式编程(Procedural programming)并列的编程范式。

**函数式编程(functional programming)面向对象编程(Object-oriented Programming)**的关系并不是对立的,只是两种不同的思维方式编程罢了。

3 例子

最早出现的函数式编程语言当属1958年诞生的LISP

Clojure、Haskell、Erlang、Scala、F#都是较为流行的函数式语言,而包括Python,JavaScript,Java的主流面向对象语言都通过对匿名函数的支持,以实现对函数式编程的支持。

4 特点

1) 没有“副作用”

“副作用”(side effect)是指除了函数返回值之外,还修改了本函数之外的变量(如全局变量、系统变量),产生运算以外的其他结果。


我们先用一个最简单的例子来说明一下什么是函数式编程。

先看一个非函数式的例子:

int cnt;
void increment(){
    cnt++;
}

那么,函数式的应该怎么写呢?

int increment(int cnt){
    return cnt+1;
}

这个例子遵循了函数式编程的准则:不依赖于外部的数据,而且也不改变外部数据的值,而是返回一个新的值给你。

换句话说,函数式编程要求没有“副作用",这意味着这个函数的执行结果只从其计算结果(同时也是函数的返回值)中体现,不存在任何依赖外部变量(不包括调用函数时传递进来的参数)或修改外部变量的行为。

2) 不修改状态

不能修改变量的状态,也是函数式编程的一个重要特点(XQuery也具有该特点)。

在其他类型的语言中,变量可以用于保存状态(state)。不修改变量,意味着状态不能保存在变量中。函数式编程使用参数保存状态,最好的例子就是递归。

3) 引用透明

引用透明(Referential transparency),指的是函数的运行不依赖于外部变量或"状态",只依赖于输入的参数,任何时候只要参数相同,引用函数所得到的返回值总是相同的。

有了前面的两点,这点是很显然的。其他类型的语言,函数的返回值往往与系统状态有关,不同的状态之下,返回值是不一样的。这就叫"引用不透明",很不利于观察和理解程序的行为。

4) 函数与其他数据类型一样

函数式一等公民(first class),这是说函数与其他数据类型一样,即可以将一个函数赋值给一个变量,也可以作为一个参数,传入给另一个函数,或者作为一个函数的返回值。

// 一个函数赋值给一个变量
var print = function(i){ console.log(i);};

// 一个函数作为一个参数,传入给另一个函数
[1,2,3].forEach(print);

// 一个函数作为另一个函数的返回值
function A() {
    return function(i){ console.log(i);};
}

5) 最小I/O

函数式编程设计的动机,最初是为了处理运算(computation),不考虑系统的读写(I/O)。

而在实际应用中,不存在I/0行为是不可能的。因此,在编程过程中,只要求把I/O操作限制到最小,不要进行不必要的I/O操作。

5 意义

1) 易于检查代码正确性

函数式编程不依赖、也不会改变外界的状态,只要给定输入参数,返回的结果必定相同。因此,每一个函数都可以被看做独立单元,很有利于进行单元测试(unit testing)和除错(debugging),

对于测试着来说,这是梦寐以求的。对于任何一个待测试的函数,只需要关注其输入的参数,不需理会函数之间的调用关系,也不用精心设置外部状态。唯一需要做的就是将输入参数的极端值输入给函数(而在C++或Java中,只检查函数的返回值显然是不够的–我们要考虑这个函数可能修改的外部状态)。

2) 易于模块化(耦合度低)

每一个函数都可以被看做独立单元,这意味着函数之间没有相互依赖(耦合)。

3)易于并发编程

函数式编程不需要考虑"死锁"(deadlock),因为它不修改变量,所以根本不存在"锁"线程的问题。不必担心一个线程的数据,被另一个线程修改,所以可以很放心地把工作分摊到多个线程,部署"并发编程"(concurrency)。

4) 代码热部署

有时,如果要在Windows上安装更新,就必须不断地重启计算机。Unix系统一直以来有一个更好的架构:安装更新时只需停止与其相关的组件,而不是整个操作系统。

即使如此,对于大型的服务器应用程序来说,这仍旧无法让人接受。程控交换系统需要100%的时间都在运行。因为如果由于升级而无法接通紧急电话,那很可能会要人命的。同样,华尔街的公司也没有理由必须在周末暂停系统来更新软件。

理想的情况是,在完全不停止系统任何组件的情况下来更新相关的代码。在命令式程序的世界里,这是不可能的。想想在Java运行过程中卸载一个类并且加载一个新的类。即使我们真的可以这样做,这个类的所有实例也都不能用了,因为这个类的状态丢失了。我们需要复杂的版本控制代码来恢复这些状态:需要把运行中实例的都序列化,销毁它们,用新的类创建新的实例,最后载入先前被序列化的数据,并祈祷着加载代码确实能将数据迁移到新的实例中。更痛苦的是,每一次改变代码的时候,我们都必须手动编写这些迁移程序。这些迁移代码不仅要迁移实例,而且还不能破坏对象间的关系。这些听来理论上可行,但实践起来可不容易。

在函数式程序中,所有的状态都存储在栈中,并且通过参数传递给函数。这使得热部署轻而易举!实际上,我们需要做的只是比较一下工作中的代码和新版本的代码,然后部署变化的部分。剩下的工作将由一个语言工具自动完成!

Reference