函数式编程简介
函数式编程无疑是目前的热门技术话题之一,值得每个开发者认真研究。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!";
}
}
不产生可观察的副作用
副作用是除了返回值以外,在被调用函数之外可观察到的任何状态变化。包括:
- 修改外部变量或对象的属性(例如,全局变量,父函数范围内的变量)
- 控制台日志输出
- 屏幕输出
- 写文件
- 输出到网络
- 调用外部进程
- 调用其它含有副作用的函数
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
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 :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
:移除字符串两端的空白字符 -
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
推荐阅读
- 一起来学习C语言的字符串转换函数
- C语言字符函数中的isalnum()和iscntrl()你都知道吗
- C语言浮点函数中的modf和fmod详解
- C语言中的时间函数clock()和time()你都了解吗
- python青少年编程比赛_第十一届蓝桥杯大赛青少年创意编程组比赛细则
- 概率论/统计学|随机变量 的 分布函数 与 概率密度函数 的区别
- HTML基础--基本概念--跟着李南江学编程
- 我的软件测试开发工程师书单
- vue组件中为何data必须是一个函数()
- 芯灵思SinlinxA33开发板Linux内核定时器编程