函数式编程简介

函数式编程无疑是目前的热门技术话题之一,值得每个开发者认真研究。iOS、Android这几年不约而同的都更换了编程语言,从Objective-C到Swift,从Java到Kotlin,一个明显的改变就是,语言不再是纯粹面向对象的,都加入对函数式编程的支持。
读过《代码大全》的朋友应该对“软件复杂度”一词印象深刻,“软件复杂度”估计是全书出现频率最高的词了,也是技术发展的核心因变量。很多编程技巧、思想都是为了控制和降低软件复杂度。

复杂度就是任何使软件难以理解或修改的东西。— John Outerhout
回想一下被自己或同事的一大坨代码折磨的可怕情景,马上要发版上线了,突然发现有一个bug需要修改,令人沮丧的是,代码千缠百结,动一发而牵全身,只能硬着头皮碰运气。有什么解决办法吗?函数式编程或许有所帮助。
函数式编程中的不可变性、纯函数等概念帮助我们消除了函数的副作用,有效降低了代码的复杂度。
什么是函数式编程?
函数式编程是一种编程范式(构建计算机程序结构和元素的风格),将计算视为函数的求值,避免状态变化和可变数据。— 维基百科
一种声明式编程范式,因为编程是用表达式或声明而不是语句来完成的。
函数式代码是幂等的:函数的返回值只依赖于其参数,相同的参数永远产生相同的返回值。相反,在命令式编程中,除了函数的参数,全局状态也会影响函数的返回值。消除副作用,即不依赖于函数输入的状态变化,可以使代码更容易理解,这是函数式编程的核心动机。
前面的话翻译自维基百科,有些过于书面化。下里巴人的话是:
函数式编程是通过纯函数的组合来构建软件的一种编程方式,强调避免共享状态、可变数据和副作用。函数式编程是声明式的,而不是命令式的,应用状态通过纯函数流动。对比面向对象编程,其应用状态通常是共享的,并且与对象中的方法关联。
函数式编程是一种编程范式,也就是说,它是基于一些基本的、决定性的原则来构建软件的一种方法。其它的常见编程范式包括面向对象、面向过程、面向协议等。注意函数式编程与面向过程编程的区别,很多人错误地认为传统的C语言编程就是函数式编程,真是让人啼笑皆非。
函数式代码通常比命令式或面向对象的代码更简洁、可预测性更强、更易于测试 -- 不过,如果你对函数式编程和其常见模式不了解,函数式代码也可能看起来头疼,因为其信息密度更高。
所有的声明式编程,概念都相对比较多,用过Rx的应该深有体会。函数式编程也不例外,涉及到的概念比较多,学习曲线比较陡,随便来几个概念感受一下:Monad、Applicative、Functor。生僻的单词有没有激起你想逃跑的本能?
不要被生僻词吓跑,它并没有看起来那么恐怖。
暂且不用管Monad这种看起来似乎很深奥的家伙,作为初学者,只要先掌握以下概念即可:
  • 纯函数
  • 函数组合
  • 避免共享状态
  • 避免可变状态
  • 避免副作用
请牢记这几个概念,这是函数式编程的核心。
纯函数 函数式编程的第一个基础概念就是纯函数。纯函数是什么?如何使函数变“纯”?
如何判断一个函数是否为纯函数?对“纯”的定义如下:
  • 参数相同,则返回值相同(也称为确定性
  • 不会引起任何可观察的副作用
参数相同,则返回值相同
让我们来实现一个计算圆形面积的函数。一种非纯函数的实现方式是,接受一个radius参数,然后返回radius * radius * PI:
let PI = 3.14; const calculateArea = (radius) => radius * radius * PI; calculateArea(10); // returns 314.0

为什么这不是纯函数?因为它使用了一个全局变量,而不是将其作为参数传入。
假设我们需要提高PI的精度,将其改为3.1415926,对于同样的参数10,上面函数的返回值将改变。
重写一下:
let PI = 3.14; const calculateArea = (radius, pi) => radius * radius * pi; calculateArea(10, PI); // returns 314.0

现在我们将PI作为参数传入,除了输入参数,函数不访问任何外部对象。毫无疑问,对于相同的参数,函数必然返回相同的结果。
读取文件 如果函数读取外部文件,则函数不是纯函数 — 文件的内容可能会改变。
const charactersCounter = (text) => `Character count: ${text.length}`; function analyzeFile(filename) { let fileContent = open(filename); return charactersCounter(fileContent); }

随机数 任何依赖随机数生成的函数都不是纯函数。
function yearEndEvaluation() { if (Math.random() > 0.5) { return "You get a raise!"; } else { return "Better luck next year!"; } }

不产生可观察的副作用
副作用是除了返回值以外,在被调用函数之外可观察到的任何状态变化。包括:
  • 修改外部变量或对象的属性(例如,全局变量,父函数范围内的变量)
  • 控制台日志输出
  • 屏幕输出
  • 写文件
  • 输出到网络
  • 调用外部进程
  • 调用其它含有副作用的函数
Haskell和其它函数式编程语言通常使用monads从纯函数中隔离和封装副作用。monads的概念比较深,足够写一本书了,感兴趣的可以自己查资料了解。
现在我们需要知道的是,副作用需要与软件的其它部分隔离。如果将副作用与其它代码逻辑分开,软件将更易于扩展、重构、调试、测试和维护。这也是大多数前端框架鼓励将状态管理和组件绘制独立、松散耦合地维护的原因。
【函数式编程简介】假设我们要实现一个对整数加1的函数。
let counter = 1; function increaseCounter(value) { counter = value + 1; }increaseCounter(counter); console.log(counter); // 2

纯函数实现:
let counter = 1; const increaseCounter = (value) => value + 1; increaseCounter(counter); // 2 console.log(counter); // 1

函数式编程不鼓励可变性。
如果我们遵循这两个简单的原则,我们的代码会变得更容易理解。每个函数都是隔离的,不会影响系统的其它部分。
纯函数是稳定的、一致的和可预测的。给定相同的参数,纯函数始终返回相同的结果。我们不需要考虑相同参数而结果不同的情况— 因为它永远不会发生。
纯函数的好处
代码明显更易于测试。不需要模拟任何东西,所以我们可以用不同的上下文对纯函数进行单元测试:
  • 给定参数A,期望返回值B
  • 给定参数C,期望返回值D
一个简单的例子是,函数接受一个数字的列表,对列表中的每个数字加1:
let list = [1, 2, 3, 4, 5]; const incrementNumbers = (list) => list.map(number => number + 1);

接受numbers数组,使用map对每个数字加1,返回新的数字列表。
incrementNumbers(list); // [2, 3, 4, 5, 6]

对于输入[1, 2, 3, 4, 5],期望的输出是[2, 3, 4, 5, 6]
不可变性
不随时间而改变,或不可改变。
当数据不可变时,其状态在创建之后不可更改。如果需要改变一个不可变对象,那就重新创建一个。
我们经常使用for循环,for循环中有几个变量:
var values = [1, 2, 3, 4, 5]; var sumOfValues = 0; for (var i = 0; i < values.length; i++) { sumOfValues += values[i]; }sumOfValues // 15

每次迭代,改变isumOfValue的状态。
如果要求不改变外部状态,应该如何做呢?用递归。
let list = [1, 2, 3, 4, 5]; let accumulator = 0; function sum(list, accumulator) { if (list.length == 0) { return accumulator; }return sum(list.slice(1), accumulator + list[0]); }sum(list, accumulator); // 15 list; // [1, 2, 3, 4, 5] accumulator; // 0

初看可能不太容易理解,sub函数接受一个数字列表以及当前累加值,每次迭代将第一个元素加到累加值上,然后用其它元素和新的累加值进行下一轮迭代,直到列表为空。
通过使用迭代,我们保持了状态的不变性。listaccmulator在运算过程中没有发生变化。
可以使用reduct实现此函数,见后面高阶函数部分。
注意:此处的代码其实变得更难以理解了,这也是纯函数式编程难以流行的原因。真正的高手,打的都是组合拳,适可而止很重要,否则过犹不及。
对各种对象做转换是很常见的任务。比如,有一个字符串,我们需要将其转换为url slug(看一下浏览器的地址栏,用-分割的名称就是url slug)。
下面是Ruby的OOP实现,我们创建一个类UrlSlugify,此类有一个slugify方法用来实现此转换。
class UrlSlugify attr_reader :textdef initialize(text) @text = text enddef slugify! text.downcase! text.strip! text.gsub!(' ', '-') end endUrlSlugify.new(' I will be a url slug').slugify! # "i-will-be-a-url-slug"

此处是命令式编程,明确说明了slugify的步骤 — 首先转小写,然后移除无用的空格,最后用连字符替换剩余的空格。
在处理过程中,我们改变了输入状态。
不改变输入状态的方式应该如何实现?可以用函数组合或函数链。函数的结果作为下一个函数的输入,而不改变原始输入。
const string = " I will be a url slug"; const slugify = string => string .toLowerCase() .trim() .split(" ") .join("-"); slugify(string); // i-will-be-a-url-slug

此处:
  • toLowerCase:将字符串转化为小写
  • trim:移除字符串两端的空白字符
  • splitjoin:替换匹配字符
通过以上四个函数的组合,我们实现了slugify
引用透明性 我们来实现一个平方函数:
const square = (n) => n * n;

给定相同的输入,此纯函数总是返回相同的结果。
square(2); // 4 square(2); // 4 square(2); // 4 // ...

给定参数2square函数总是返回4。所以我们可以用4替换square(2),这就叫引用透明性
基本上,如果一个函数对于相同的输入,总是产生相同的结果,那么就可以说它是引用透明的。
纯函数 + 不可变数据 = 引用透明性
有了这个概念,我们就可以将函数“记忆化”(一种程序优化技术,通过缓存复杂函数的结果,当输入相同时直接返回缓存结果,以此来提升程序的运行速度)。
假设有以下函数:
const sum = (a, b) => a + b;

有如下调用:
sum(3, sum(5, 8));

sum(5, 8)等于13,此函数的结果总是13。因此上面的表达式等于:
sum(3, 13);

此表达式的结果总是16。我们可以将整个表达式用一个数字常量来替换,将其记忆化。
函数是第一类对象 函数是第一类对象,是说函数被作为值对待,可作为普通数据使用。
函数作为第一类对象使得可以:
  • 使用常量或变量引用它
  • 将其作为参数传入其它函数
  • 将其作为函数返回值
此思想将函数视为值,并作为数据传递。通过这种方式,我们可以组合不同的函数来创建具有新行为的新函数。
假设有一个函数,对两个输入值求和并加倍:
const doubleSum = (a, b) => (a + b) * 2;

另一个函数,对两个输入值求差值并加倍:
const doubleSubtraction = (a, b) => (a - b) * 2;

两个函数逻辑类似,不同之处在于运算符函数。如果我们可以将函数作为值,并作为参数传递,我们可以构建一个函数,运算符函数作为其参数。
const sum = (a, b) => a + b; const subtraction = (a, b) => a - b; const doubleOperator = (f, a, b) => f(a, b) * 2; doubleOperator(sum, 3, 1); // 8 doubleOperator(subtraction, 3, 1); // 4

此处有运算符函数参数f,用于处理参数ab。通过sumsubtraction函数与doubleOperator函数的组合创造了新的行为。
高阶函数 当我们讨论高阶函数时,我们指的是这样一个函数:
  • 将一个或多个函数作为参数,或
  • 返回一个函数作为返回值
上面的doubleOperator函数是一个高阶函数,因为其将运算符函数作为参数。
你可能已经听说过filtermapreduce,最常用的三个操作容器(数组、字典等)的高阶函数,我们再一起看一下。
Filter
给定一个集合,我们希望根据某个属性做筛选。filter函数期望一个布尔值,用于判定元素是否应该包含在结果集合中。如果回调函数返回true,那么当前元素将会包含在结果集合中,否则不会。
一个简单的例子是,我们希望从一个整数列表中筛选出所有偶数。
命令式 命令式的实现方式:
  • 创建一个空的evenNumbers数组
  • 遍历numbers数组
  • 将偶数放入evenNumbers数组中
var numbers = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; var evenNumbers = []; for (var i = 0; i < numbers.length; i++) { if (numbers[i] % 2 == 0) { evenNumbers.push(numbers[i]); } }console.log(evenNumbers); // (6) [0, 2, 4, 6, 8, 10]

我们也可以使用filter高阶函数,向其传递一个even函数:
const even = n => n % 2 == 0; const listOfNumbers = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; listOfNumbers.filter(even); // [0, 2, 4, 6, 8, 10]

在Hacker Rank FP(类似于LeetCode、牛客网)上有一个Filter数组问题,过滤一个整数数组,输出小于x的所有数字。
一种命令式解法可以是:
var filterArray = function(x, coll) { var resultArray = []; for (var i = 0; i < coll.length; i++) { if (coll[i] < x) { resultArray.push(coll[i]); } }return resultArray; }console.log(filterArray(3, [10, 9, 8, 2, 7, 5, 1, 3, 0])); // (3) [2, 1, 0]

精确地说明函数需要做什么 - 遍历集合,将当前数字与x比较,如果满足条件则放入结果数组resultArray
声明式 如果我们想要使用filter高阶函数的更加声明化的解法呢?
一种声明式的解法可以是:
function smaller(number) { return number < this; }function filterArray(x, listOfNumbers) { return listOfNumbers.filter(smaller, x); }let numbers = [10, 9, 8, 2, 7, 5, 1, 3, 0]; filterArray(3, numbers); // [2, 1, 0]

smaller函数中使用this初看起来可能有点儿怪,不过它本身很好理解 (JS的骚语法,其它常见编程语言未必有类似用法)。filter函数的第二个参数会成为this,在上例中,this表示3(x)。
也可以这样操作maps。假设有一个包含nameage的map:
let people = [ { name: "TK", age: 26 }, { name: "Kaio", age: 10 }, { name: "Kazumi", age: 30 } ];

我们想要筛选出所有21岁以上的人:
const olderThan21 = person => person.age > 21; const overAge = people => people.filter(olderThan21); overAge(people); // [{ name: 'TK', age: 26 }, { name: 'Kazumi', age: 30 }]

Map
map用来对集合做转换。
map将一个函数应用于集合的所有元素,根据函数的结果构建一个新的集合,以实现对集合的转换。
还是上面的people数组,不过此次不是根据年龄做筛选,而是想得到每个人的描述,类似TK is 26 years old。也就是说,对于每个人,我们希望得到一个字符串:name is :age years old,其中:name:agepeople数组中每个元素的属性。
一个命令式的实现方式:
var people = [ { name: "TK", age: 26 }, { name: "Kaio", age: 10 }, { name: "Kazumi", age: 30 } ]; var peopleSentences = []; for (var i = 0; i < people.length; i++) { var sentence = people[i].name + " is " + people[i].age + " years old"; peopleSentences.push(sentence); }console.log(peopleSentences); // ['TK is 26 years old', 'Kaio is 10 years old', 'Kazumi is 30 years old']

而声明式的实现方式可能是:
const makeSentence = (person) => `${person.name} is ${person.age} years old`; const peopleSentences = (people) => people.map(makeSentence); peopleSentences(people); // ['TK is 26 years old', 'Kaio is 10 years old', 'Kazumi is 30 years old']

Hacker Rank上有一个修改列表问题,将数组中的每个数字修改为其绝对值。
例如,对于数组[1, 2, 3, -4, 5],结果应该是[1, 2, 3, 4, 5]
一种简单的实现方案是对集合中的每个数进行就地更新:
var values = [1, 2, 3, -4, 5]; for (var i = 0; i < values.length; i++) { values[i] = Math.abs(values[i]); }console.log(values); // [1, 2, 3, 4, 5]

我们使用Math.abs函数将每个数转换为其绝对值,然后就地更新。
这不是函数式的实现方式。
我们前面介绍了不可变性,强调了不可变性对函数一致性、可预测性的重要作用。既然想要构建一个绝对值组成的新集合,为什么不用map呢?
首先看一下Math.abs函数如何操作一个数字:
Math.abs(-1); // 1 Math.abs(1); // 1 Math.abs(-2); // 2 Math.abs(2); // 2

Math.abs将数字转换为其绝对值。
我们已经知道如何获取一个数字的绝对值,我们可以将此函数传入map函数。还记得高阶函数可以接受其它函数作为其参数吗?
let values = [1, 2, 3, -4, 5]; const updateListMap = (values) => values.map(Math.abs); updateListMap(values); // [1, 2, 3, 4, 5]

是不是简洁、优雅很多?
Reduce
接受一个函数和一个集合,将其组合为一个新的值。
一个常见的例子是,获取一个订单的总额。假设我们在某个电商网站上,将Product 1Product 2Product 3Product 4加入了购物车(订单),我们想要计算购物车商品的总金额。
一种命令式的实现方式是,遍历商品列表,累加商品的金额。
var orders = [ { productTitle: "Product 1", amount: 10 }, { productTitle: "Product 2", amount: 30 }, { productTitle: "Product 3", amount: 20 }, { productTitle: "Product 4", amount: 60 } ]; var totalAmount = 0; for (var i = 0; i < orders.length; i++) { totalAmount += orders[i].amount; }console.log(totalAmount); // 120

使用reduce函数,我们可以构建一个用来amount sum的函数,并将其传给reduce函数。
let shoppingCart = [ { productTitle: "Product 1", amount: 10 }, { productTitle: "Product 2", amount: 30 }, { productTitle: "Product 3", amount: 20 }, { productTitle: "Product 4", amount: 60 } ]; const sumAmount = (currentTotalAmount, order) => currentTotalAmount + order.amount; const getTotalAmount = (shoppingCart) => shoppingCart.reduce(sumAmount, 0); getTotalAmount(shoppingCart); // 120

此处,我们有购物车商品列表shoppingCart,函数sumAmount接受当前总额currentTotalAmount和订单order对象以执行计算。
getTotalAmount函数使用sumAmount和起始值0对shoppingCartreduce
另外一种获取总额的方式是组合mapreduce函数。什么意思呢?我们可以首先使用mapshoppingCart转换为amount的数组,然后再使用sumAmount函数做reduce
const getAmount = (order) => order.amount; const sumAmount = (acc, amount) => acc + amount; function getTotalAmount(shoppingCart) { return shoppingCart .map(getAmount) .reduce(sumAmount, 0); }getTotalAmount(shoppingCart); // 120

getAmount函数接受订单对象order并返回金额amount。此处,我们有[10, 30, 30, 60]。然后reduce累加所有的值以得到结果。漂亮!
我们再来看一个将上面讲解的几个高阶函数组合应用的例子。
依然以购物车shopping cart为例,假设购物车中有以下商品:
let shoppingCart = [ { productTitle: "Functional Programming", type: "books", amount: 10 }, { productTitle: "Kindle", type: "eletronics", amount: 30 }, { productTitle: "Shoes", type: "fashion", amount: 20 }, { productTitle: "Clean Code", type: "books", amount: 60 } ]

我们希望获得购物车中所有书籍的总额。如何做呢?
  • 筛选出所有的书籍(filter)
  • 将商品列表转换为金额的列表(map)
  • 将所有值累加(reduce)
let shoppingCart = [ { productTitle: "Functional Programming", type: "books", amount: 10 }, { productTitle: "Kindle", type: "eletronics", amount: 30 }, { productTitle: "Shoes", type: "fashion", amount: 20 }, { productTitle: "Clean Code", type: "books", amount: 60 } ]const byBooks = (order) => order.type == "books"; const getAmount = (order) => order.amount; const sumAmount = (acc, amount) => acc + amount; function getTotalAmount(shoppingCart) { return shoppingCart .filter(byBooks) .map(getAmount) .reduce(sumAmount, 0); }getTotalAmount(shoppingCart); // 70

参考资源
  • Functional Programming Principles
  • What is Functional Programming?
  • Functional Programming Paradigm

    推荐阅读