函数式编程无疑是目前的热门技术话题之一,值得每个开发者认真研究。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
每次迭代,改变i
和sumOfValue
的状态。
如果要求不改变外部状态,应该如何做呢?用递归。
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
函数接受一个数字列表以及当前累加值,每次迭代将第一个元素加到累加值上,然后用其它元素和新的累加值进行下一轮迭代,直到列表为空。
通过使用迭代,我们保持了状态的不变性。list
和accmulator
在运算过程中没有发生变化。
可以使用reduct
实现此函数,见后面高阶函数部分。
注意:此处的代码其实变得更难以理解了,这也是纯函数式编程难以流行的原因。真正的高手,打的都是组合拳,适可而止很重要,否则过犹不及。
对各种对象做转换是很常见的任务。比如,有一个字符串,我们需要将其转换为url slug
(看一下浏览器的地址栏,用-
分割的名称就是url slug
)。
下面是Ruby的OOP实现,我们创建一个类UrlSlugify
,此类有一个slugify
方法用来实现此转换。
class UrlSlugify
attr_reader :text
def initialize(text)
@text = text
end
def slugify!
text.downcase!
text.strip!
text.gsub!(' ', '-')
end
end
UrlSlugify.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
:移除字符串两端的空白字符 -
split
和join
:替换匹配字符
通过以上四个函数的组合,我们实现了slugify
。
引用透明性
我们来实现一个平方函数:
const square = (n) => n * n;
给定相同的输入,此纯函数总是返回相同的结果。
square(2); // 4
square(2); // 4
square(2); // 4
// ...
给定参数2
,square
函数总是返回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
,用于处理参数a
和b
。通过sum
和subtraction
函数与doubleOperator
函数的组合创造了新的行为。
高阶函数
当我们讨论高阶函数时,我们指的是这样一个函数:
- 将一个或多个函数作为参数,或
- 返回一个函数作为返回值
上面的doubleOperator
函数是一个高阶函数,因为其将运算符函数作为参数。
你可能已经听说过filter
、map
和reduce
,最常用的三个操作容器(数组、字典等)的高阶函数,我们再一起看一下。
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。假设有一个包含name
和age
的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
和:age
是people
数组中每个元素的属性。
一个命令式的实现方式:
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 1
、Product 2
、Product 3
和Product 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对shoppingCart
做reduce
。
另外一种获取总额的方式是组合map
和reduce
函数。什么意思呢?我们可以首先使用map
将shoppingCart
转换为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