“函数式编程” ?

1,149 阅读21分钟

前言:我相信不单是我,大多数掘友对“函数式编程”的印象都应该是:她应该很美,但她戴着面纱。(文章梳理完我大概也知道为什么了,其实就是大多数文章讲复杂、讲多了,至于为何怎么说,那就请大家跟着我一起来重新梳理吧。)全文采用自我问答的方式去写,其实也是我在理解过程中自己的解疑过程,前半段比较闷,后面更精彩噢。

一、函数式编程

函数式编程是一种编程范式,理解为一种公认的典型编码风格即可。

函数式编程这种编码风格本身对我来说并非是最吸引我的地方,最吸引我的是在尝试使用它的那一刻,它能锻炼自己在遇到问题时尝试抽象化问题并寻找更加高效的解决方式,而这个过程恰恰让我感到满足。

如果你还在纠结函数式编程哪里值得用,并还在四处寻找答案,不妨停下来,了解它并用起来,可能就明白了。

问:为什么JS强调函数式编程?

JS里函数本身就是第一公民,最关键它能作为参数,这种独天得厚的条件自然而然会与之关联。我们在用JS开发时不可能不用到函数,既然用到,自然而然会在开发中寻找更好的使用方式,在我看来函数式编程是过来人对函数如何更好运用的一种经验,它并非是取代,而是加强。至于网上有些文章非要让它跟其他编程风格比个孰好孰坏,其实大可不必,对于程序员而言它只是一种编程风格,有自己优点和缺点,了解并在合适的地方用起来就可了。

问: 函数式编程有什么不同?

在我看来,最本质的不同是它需要你把问题涉及的对象抽象化,找到一种通用的解决方式,并用它来解决问题。而我们没遇到它之前我们可能会努力让自己清楚每一个具体对象的每一步该如何执行。

举个例子:判断两数之和是不是偶数

let sum = a + b;
let isSumEven = sum % 2 === 0;
console.log(isSumEven); // 每一条语句都在强调你知道怎么做加法和怎么判断偶数

那假设你不知道怎么做加法,也不知道怎么判断偶数🤓,咋办?

而这时恰好有小伙伴提供了两个函数getSumisEven,告诉你分别用来求和和判断偶数的,那你自然而然会写以下代码:

let isSumEven = isEven(getSum(a, b));
console.log(isSumEven);

这时让你再判断c和d之和是不是偶数,你虽不知道怎么做加法和判断偶数,但你明确知道有两个工具函数声明自己可以做,你当然也是继续用它们:

isSumEven = isEven(getSum(c, d));
console.log(isSumEven);

而这里的getSumisEven就是你的小伙伴通过抽象化它的处理对象,封装起来的一个通用型的工具函数,在实际编码中,这里的小伙伴有可能是第三方工具库(ramda、lodash等),当然也可能是你的小同事,或者就是自己。

这里分别对应两个名词:命令式编程 vs 声明式编程,命令式就是每一步怎么做你都得知道并写清楚;而声明式就是每一步怎么做你可以不用自己写甚至你可以完全不知道内部怎么实现的,你只要知道要实现的目标和采用什么工具函数去实现目标就可了。

在我看来,使用函数式编程的过程其实已经在锻炼你学着用更高维的角度思考并解决问题了。

问:多多使用函数 = 函数式编程 ?

函数式编程强调编程以函数使用为主,所以个人认为这句话其实也没问题,但由于存在一些公认的使用标准(过来人留下的宝藏),所以如果想写一段好的函数式编程代码,应尽量去达到相关标准。

问:那什么是函数式编程的标准?

实话说我本来尝试去整理出一些标准,但实际总结起来就一条: 尽可能通过组合纯函数的方式去编码,要理解这句话,那就需要理解“纯函数”和“组合”这两个十分重要的概念。

二、纯函数

问:什么是纯函数?

纯函数从字面上去理解其实也就是“纯粹的函数”,它的职责只有一个,就是接收参数通过内部加工输出想要的结果。如要实现“纯粹”就需要满足以下条件:

  1. 函数内不应该有外部的依赖
  2. 函数内不应该执行和函数职责无关的操作
(1)没有外部依赖 -> 相同的输入参数必然得到相同输出结果

函数如果没有依赖外部的变量,函数的输出结果将完全依赖于输入参数,按部就班的机器,用相同的输入参数在相同的计算流程语句中做计算也必然只会输出相同的结果。而倘若其中参与了外部变量的计算,那相同输入参数也将可能产生不可预料的结果,如下方例子:

let depend = 0;
const add1 = (a, b) => depend + a + b;
console.log(add1(1, 2)) // 3
...
depend = 1;             // 假如某处不小心改了这个依赖值
...
console.log(add1(1, 2)) // 4 由于依赖外部变量,导致相同输入参数经过相同执行语句后输出的结果不一致
(2)函数没有执行职责以外的操作 -> 函数无副作用

何谓副作用,就是函数除了返回结果(职责)外,还在所调用环境中产生了其他附加影响,常表现在:

  • 改动了函数外部的值(最常见)
  • I/O操作(console这类)
  • 抛出异常、错误中止
  • ajax操作 ...

举个例子比较贴合实际的例子(为了让大家真正理解,想这些例子想到🤯),下面是计算消费的程序:

let ratio = 1;                      // 折扣率为1 = 没打折
const spend = (price, amount) => {  // 这是一个计算消费值的函数(price-价格;amount-数量)
    if (amount > 10) ratio = 0.8;   // 这里有个其他操作,但数量大于10时,打8折
    return price * amount;
};

上面例子中的spend函数职责就是计算单一货物×其数量的价格,但是spend函数中却多了一个无关操作,通过数量去判断是否需要打折,进而修改外部的ratio,而这种情况就是上面所说的“副作用”,虽说函数也正常输出了结果,但却产生了对外部有影响的操作(修改了外部变量)。在上面例子中如果要去除副作用,你应该改成:


const getSpendAndRatio = (price, amount) => {
    let ratio = 1;
    if (amount > 10) ratio = 0.8;
    return {                        // 将计算出的折扣也作为输出结果输出
        total: price * amount,
        ratio: ratio
    };
};

上面这段代码改完后,除了输出结果就不会有其他副作用了。当然这里也是想说另一件事:如果你想消除函数副作用,不妨考虑下将引起副作用的操作也转化为结果产出。 这就像你把副业也做成正业,那就没人会说你不务正业🤪。

可以说,纯函数只是一个值到另一个值计算过程的描述,它唯一的职责就是调用的时候顺着固定流程,将输入参数与固定的内部参数做运算并产出一个结果,不会去执行其他无关操作。

问:没有输入或没有输出的函数是纯函数吗?

我看过许多文章,都没有得到答案,所有人笔中的纯函数都必然涉及到输入和输出,但我的看法就是还是得看能不能满足以上说的两点,如果没有输入,但它能固定输出同一个结果,且没有副作用,那为何不能称作是纯函数?

问:我们不可能做到把所有函数都写成纯函数,那还能称作函数式编程吗?

我个人的看法是函数式编程并非是说应用于整个项目,如能在合适的地方使用,并发挥出它的优势出来,那别人不管,我愿意称那段代码是优秀的函数式编程😎。这不是死板的规章制度,而是灵活的编程风格~

问:什么是引用透明?

这个概念我在好多个文章看到,但是各有各的说法,有的甚至难以理解,我查阅了好一些文章也试图真正理解,最后是更偏向以下这种说法:如果某处函数调用可以用它调用后的返回值来代替,那该函数的调用满足引用透明。如果霎时间没看懂,那看看下面的例子,同样使用上面求消费值的例子:

let ratio = 1;
const spend = (price, amount) => {
    if (amount > 10) ratio = 0.8;  // 但数量大于10时,打8折
    return price * amount;
};
let result = ratio * spend(1, 20); // 16;

如果我们将spend(1, 20)这个表达式换成它的输出结果20:

let ratio = 1;
// const spend = (price, amount) => {
//    if (amount > 10) ratio = 0.8;
//    return price * amount;
// };
let result = ratio * 20; // 20; 

可以看到替换后输出的结果值和原来的输出结果是不一致,所以我们说spend(1, 20)是不满足引用透明的。反之我们用上面那个已经消除副作用的例子:

const getSpendAndRatio = (price, amount) => {
    let ratio = 1;
    if (amount > 10) ratio = 0.8;
    return {
        total: price * amount,
        ratio: ratio
    };
};
const { total, ratio } = getSpendAndRatio(1, 20);
let result = ratio * total; // 16

getSpendAndRatio(1, 20)这个表达式换成它的输出结果{ total:20, ratio: 0.8 }:

const { total, ratio } = { total:20, ratio: 0.8 };
let result = ratio * total; // 16

可以看到最后的结果都是一样的,所以这里可以说getSpendAndRatio(1, 20)满足引用透明。

现在再回头看我总结的那句话,能多少理解一点了吧~ 而我拿上面的消除副作用的代码作为例子,其实也就是想说:当函数没有副作用时,它在哪进行调用都是满足引用透明的。 那到底如何去理解“透明”两字,我的看法是,对于没有副作用的函数,正是因为我们在调用它之后不用担心它内部的一些操作会对外部产生不可预料的影响,所以可以将它参数 -> 结果这个转化过程视作透明的。

“若以同样的参数调用同一函数两次,得到的结果总是相同的,这被称作引用透明性”,这个是「Haskell趣味指南」书中的描述,但我并不认同,因为我看不到“引用”和“透明”表现在哪,如果我是科学家,我不会弄这么个词来增加求知者的认知负担🤪。另外,「维基百科」的说法是纯函数式编程完全防止副作用并提供引用透明性。

问:纯函数在函数式编程中的意义在哪?

纯函数由于上面所说的两个特性,在开发中,也让我们有了更多的发挥空间:

(1)结果可推测 & 便于测试

这主要归功于特性一“相同的输入参数必然得到相同输出结果”,如果某个函数满足这个特性,我们就可以将这个函数看成是值与值的一种映射关系f(x) = y,进而也可以通过输入值去推测输出结果。在开发中就很便于我们测试了,通过判断推测值和输出结果是否一致,来判断函数是否符合要求。

(2)函数复用

这主要归功于特性二“无副作用”,无副作用让我们可以放心将其用在任何地方,不担心它会影响到外部的值,导致出现莫名其妙的问题。

三、组合

函数组合,如果不进行具体的介绍,从字面上去理解,我们基本上会理解成函数嵌套调用形式:

const getSum = (a, b) => a + b;
const isEven = (value) => value % 2 === 0;
let isSumEven = isEven(getSum(a, b));
console.log(isSumEven(1, 2));

也就是例子中isEven(getSum(a, b))这种形式,这种函数嵌套调用的形式能够对输入值逐步加工成最终输出结果,这确实是一种组合方式,但实际上,有更好的方式去实现纯函数的组合,也就是我即将讲到的Pointfree编程风格,它为函数式编程中的组合方式提供了一种更酷的形式,我愿称之为进阶版的函数式编程,类似用法如下:

const getSum = (a, b) => a + b;
const isEven = (value) => value % 2 === 0;
const isSumEven = compose(isEven, getSum); // compose方法是其中一种组合纯函数的工具函数,执行时从右到左
console.log(isSumEven(1, 2));

从形式变化来看:

func4(func3(func2(func1(value)))) => compose(func4, func3, func2, func1)(value)

变化后的形式是如此清爽和令人着迷。

问:用嵌套调用纯函数的方式写代码,也是函数式编程吧?

是的,嵌套调用也是组合的一种方式,只是如果有更好的组合方式,我相信我们都愿意选择更好的。

问:里面的“compose”是什么?

未命名文件.png

compose是一个将纯函数运算过程组合起来的工具函数。对于嵌套调用的形式,每调用下一步的函数都需要将上一步的输出作为参数传入,而compose函数将每个的小的运算过程按顺序组合成一个大的运算过程,我们只需要传入初始的参数,它便会按顺序执行并最后输出结果。

实际上compose函数的原理其实也就是“嵌套调用”,只不过它为我们提供了一个更加方便理解,更加美观的编码方式,compose的源码实现:

function compose(...funs) {
  // 非简化版,但清晰一点
  funs = funs.reverse()
  return function (val) {
    let result = val
    // 这里可以看到其实内部也是按顺序把单个步骤的输出作为入参传给下一步的执行函数
    for (let i = 0; i < funs.length; i++) {
      result = funs[i](result)
    }
    return result
  }
  // 简化版
  // return val => funs
  //   .reverse()
  //   .reduce((stepResult, stepFuc) => stepFuc(stepResult), val)
}

至于为什么还调用了reverse,是为了和嵌套调用的写法保持一致,都是从右至左的顺序调用执行函数。如果从左至右的顺序调用,这是个新的组合工具函数pipe(管道),使用起来就像如下方所示:

func4(func3(func2(func1(value)))) => pipe(func1, func2, func3, func4)(value)

采用Pointfree编程风格的组合方式,如果再搭配一个比较良好的函数命名习惯,那会有个更好编程体验,比如:

const goods = [
  { name: '帽子', price: 66, amount: 1 },
  { name: '衣服', price: 88, amount: 5 },
  { name: '袜子', price: 10, amount: 3 },
];

const calculateCost = (goods) => goods.reduce((result, item) => result + item.price * item.amount, 0);
const discount = (cost) => cost * 0.8;
const format = (cost) => '$' + cost.toFixed(2);

const getCostString = compose(format, discount, calculateCost);

console.log(getCostString(goods)); // $428.80

那你可以从compose(format, discount, calculateCost)里面就清晰了解整个求值的执行流程:计算花费 -> 打折 -> 格式化。

看完上面的例子,好奇心强的小伙伴肯定要问了:函数最多只能返回一个结果,你上面用来组合的函数都是碰巧只需要一个输入参数,如果有函数需要多个输入参数呢,就像上面例子中,打多少折和格式化中的货币符号都需要可配置那要怎么弄?

上面说到得就是函数组合中的“单一输入限制”,原因正是由于函数最多只能返回一个结果,所以传给下一个执行函数的时候也必然最多只能是一个输入参数,要解决这个问题就需要使用到部分函数应用技术了。

问:什么是部分函数应用?

部分应用(或部分函数应用)是指固定函数的部分参数,同时生成一个用于接收剩余参数的函数。为了便于理解,我们再写个代码示例:

func(a, b, c) => func(a, b)(c); // a, b被固定,生成只需接收c参数的函数
func(a, b, c) => func(a)(b, c); // a被固定,生成只需接收b,c参数的函数

部分函数应用的关键就在它能固定一部分参数,而这里的固定换成我们编程的实现就是将参数缓存下来,然后生成一个只需接收剩余参数的方法,最后接收到剩余参数后再真正执行函数操作,就拿上面format(格式化最后花费)的函数做例子:

// 格式化最后花费,currency - 货币符号,digit - 小数后保留位数
const format = (currency, digit) => {
    return (cost) => `${currency}${cost.toFixed(digit)}`
}
// 使用的时候先固定货币符号和保留小数位
console.log(format('$', 2)(200.888)) // $200.89

对于discount函数也可以采用部分函数调用的方式:

// ratio是打多少折
const discount = (ratio) => (cost) => cost * (ratio * 0.1);

// 使用的时候先固定打折力度
console.log(discount(8)(200)) // 160

这时候我们把关注点回到上面使用compose组合的时候,我们就能通过部分函数应用,使待组合的函数都只需要一个入参,从而实现灵活配置的效果:

const getCostString = compose(format('¥', 2), discount(8), calculateCost);

console.log(getCostString(goods)); // ¥428.80

使用了"部分函数应用"的函数也被我们称为“偏函数”,不仅是用于组合,有些时候“偏函数”会有奇效,如下方例子:

const isType = (type) => {
  return (obj) => {
      return Object.prototype.toString.call(obj) === `[object ${type}]`;
  }
}
// 通过缓存固定的类型值,生成用于判断特定类型的函数
const isArray = isType('Array');
const isObject = isType('Object');

// 这样就不用每次都传类型和值两个参数
console.log(isArray(goods)); // true

四、补充

其实写到这里,我认为对于“了解并知道怎么用函数式编程”,其实已经是足够了。有些看过函数式编程相关文章的人可能就会疑惑了,为什么别人讲函数式编程的时候都会讲到高阶函数、柯里化等等,而我什么都没讲到。

在梳理函数式编程相关知识的过程中,我发现函数式编程其实并非那么不可捉摸,跟随我文章的顺序去认识它,你甚至会觉得它有点简单了。

函数式编程是一种编程范式,通过应用和组合函数来构建程序;函数式编程有时也被视为纯函数式编程的同义词。——维基百科

函数式编程,说到底就是一种编程风格,当你尽可能通过组合纯函数的方式去编码时,你就已经在写函数式编程了。

所以当你了解了纯函数和组合的概念后,就已经足够理解函数式编程了。之前看了好多篇函数式编程的文章,看着它们花了大篇幅在讲高阶函数、柯里化等概念,这让我看得云里雾里,其根本原因就在于我看了又看也没看出来了它们究竟在函数式编程中充当怎样大的分量。不过在理顺后,发现其实是不需要花太大篇幅去描述它们概念本身的,只需要简单介绍并讲清楚它们在函数式编程中是如何起到关键作用就可以了,接下来我会简单介绍并谈谈他们与函数式编程的关联,至于详细的介绍我想就不用特意写了,这些网上哪都是😂。

当然还有一些文章是结合着数学知识在谈函数式编程,这些就比较高级了,初学者不妨只用程序员的角度去学习,反正我看到函子那块我也遭不住🤣,先以最简单的方式去认识它吧,后面如果有机会,再深入了解和总结一下。这些文章看不懂,要学会原谅自己。

问:高阶函数与函数式编程的关联

高阶函数是指接收一个或多个函数作为输入参数输出一个函数的函数。高阶函数至少满足其中一条:

  • 接收一个或多个函数作为输入参数
  • 输出一个函数

在函数式编程中,用于“组合纯函数”的工具函数(compose、pipe)就是高阶函数,它接收多个执行函数,并将它们合并成一个新的执行函数。而在实现部分函数应用时我们也用到了高阶函数,通过高阶函数(结合闭包)去缓存部分数据,生成一个用于接收其余参数的新函数。

没有高阶函数,那咱们只能自己通过函数嵌套去实现函数组合运算了。所以总结下来高阶函数与函数式编程最紧密关联是:

通过“部分函数应用”技术,结合闭包缓存部分传入参数,使得函数组合的“单一输入限制”得以解决,也成就了Pointfree这种优美的编程风格。

利用高阶函数,我们还能实现获取组合中某一执行过程中的运算结果,具体操作如下:

const getMiddleResult = (func) => {
    return (preStepResult) => {
        const middleResult = func(preStepResult);
        // 实际上也就是在执行下一步之前把结果先输出来
        console.log(middleResult);
        return middleResult;
    };
}
const getCostString = compose(format('¥', 2), discount(8), getMiddleResult(calculateCost));

console.log(getCostString(goods)); // ¥428.80

问:函数柯里化与函数式编程的关联

函数柯里化是函数的一种特殊用法。「维基百科」讲:柯里化是一种将接收多个参数的函数转换为多个只接收单一参数的函数序列的技术。代码示例来理解:

func(a, b, c) => func(a)(b)(c);

感觉和“部分函数应用”很像呢,所以也导致有大部分人是分不清的呢,我看了百度百科,Emmm🤨。当一个函数只有两个参数的时候,使用“部分函数应用”和“柯里化”的效果是一样的,也难怪被人所混淆。

函数柯里化和部分函数应用一样都是基于“高阶函数”去实现的,所以比起“高阶函数”给函数式编程带去的荣誉是远远比不上的,不过它的存在也起到了一个润色的作用,具体的介绍靠大家自行查阅文章了,网上都写得很清晰。

问:函数式编程有啥缺点吗

讲了那么多优点,缺点肯定也是有的。很明显的两个缺点:

  • 性能问题 由于函数式编程的纯函数注重“无副作用”的特点,不能更改外部的值,导致传入参数都会复制一份全新的数据再往下执行流程,少量的数据执行倒也不是问题,但是一旦是大批量引用类型的值需要执行相关操作,就会占用大量内存,导致出现性能问题。
  • 可能存在过度包装 由于是强调多使用函数,难免会出现一些不必要的封装,就像随便写个遍历都想整个递归秀一下,实属没必要。另外递归也是函数式编程中的一种重要概念,就是让函数调用自身,相信你都了解到函数式编程了,总不能不知道递归吧~但是实际使用上还是要注重使用时机。

所以学了函数式编程并非就是让你丢掉命令式编程,命令式编程能让你对执行过程有一个更好的把控,也就能更好得处理性能的问题。

五、最后

虽参阅挺多文献,但为了便于对函数式编程的理解,本文主观性还是比较强的,如有错误,请多见谅,并劳烦在评论区提出,感谢大家的阅读。

End